Skip to content
Snippets Groups Projects
Commit 31ad5ddd authored by Dave Long's avatar Dave Long Committed by Wim Leers
Browse files

Issue #3518253 by longwave, wim leers: SDCs with optional images without examples cannot be placed

parent 58fc7cb3
No related branches found
No related tags found
1 merge request!880Resolve #3518253 "Sdcs with optional"
Pipeline #486052 passed
......@@ -13,7 +13,6 @@ use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\experience_builder\Entity\Component;
use Drupal\experience_builder\Storage\ComponentTreeLoader;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Allows editing the prop sources for a component.
......@@ -93,11 +92,7 @@ final class ComponentInputsForm extends FormBase {
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#method
$form['#method'] = 'dialog';
$violations = new ConstraintViolationList();
$inputs = $component->getComponentSource()->clientModelToInput($component_instance_uuid, $component, $client_model, $violations);
// Don't complain about invalid received values except to developers.
// @see https://en.wikipedia.org/wiki/Robustness_principle
assert($violations->count() === 0);
$inputs = $component->getComponentSource()->clientModelToInput($component_instance_uuid, $component, $client_model);
$form['#component'] = $component;
$form['#attributes']['data-form-id'] = 'component_inputs_form';
......
......@@ -35,8 +35,6 @@ use Drupal\experience_builder\PropSource\StaticPropSource;
use Drupal\experience_builder\PropSource\DefaultRelativeUrlPropSource;
use Drupal\experience_builder\ShapeMatcher\FieldForComponentSuggester;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
......@@ -356,7 +354,8 @@ abstract class GeneratedFieldExplicitInputUxComponentSourceBase extends Componen
): array {
$transforms = [];
assert($entity instanceof FieldableEntityInterface);
$component_schema = $this->getSdcPlugin()->metadata->schema ?? [];
$component_plugin = $this->getSdcPlugin();
$component_schema = $component_plugin->metadata->schema ?? [];
// Allow form alterations specific to XB component inputs forms (currently
// only "static prop sources").
......@@ -364,14 +363,9 @@ abstract class GeneratedFieldExplicitInputUxComponentSourceBase extends Componen
$prop_field_definitions = $settings['prop_field_definitions'];
$component = $form['#component'];
\assert($component instanceof ComponentEntity);
// To ensure the order of the fields always matches the order of the schema
// we loop over the properties from the schema, but first we have to
// exclude props that aren't storable.
$component_plugin = $this->getSdcPlugin();
$storable_props = [];
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. See PropShapeRepositoryTest::getExpectedUnstorablePropShapes()
......@@ -380,24 +374,31 @@ abstract class GeneratedFieldExplicitInputUxComponentSourceBase extends Componen
continue;
}
$component_prop = ComponentPropExpression::fromString($component_prop_expression);
$storable_props[] = $component_prop->propName;
}
foreach ($storable_props as $sdc_prop_name) {
$sdc_prop_name = $component_prop->propName;
$is_required = isset($component_schema['required']) && in_array($sdc_prop_name, $component_schema['required'], TRUE);
$prop_source_array = $client_model[$sdc_prop_name] ?? NULL;
if ($prop_source_array === NULL) {
// The client didn't send this prop but should. This is an error OR the
// data has been tampered with.
throw HttpException::fromStatusCode(Response::HTTP_BAD_REQUEST);
}
$source = PropSource::parse($prop_source_array);
$disabled = FALSE;
if (!$source instanceof StaticPropSource) {
// @todo Design is undefined for the DynamicPropSource UX. Related: https://www.drupal.org/project/experience_builder/issues/3459234
// @todo Design is undefined for the AdaptedPropSource UX.
// Fall back to the static version, disabled for now where the design is undefined.
$disabled = !$source instanceof DefaultRelativeUrlPropSource;
$source = $this->getDefaultStaticPropSource($sdc_prop_name)->withValue($prop_source_array['value'] ?? NULL);
if ($prop_source_array !== NULL) {
$source = PropSource::parse($prop_source_array);
if (!$source instanceof StaticPropSource) {
// @todo Design is undefined for the DynamicPropSource UX. Related: https://www.drupal.org/project/experience_builder/issues/3459234
// @todo Design is undefined for the AdaptedPropSource UX.
// Fall back to the static version, disabled for now where the design is undefined.
$disabled = !$source instanceof DefaultRelativeUrlPropSource;
$source = $this->getDefaultStaticPropSource($sdc_prop_name)->withValue($prop_source_array['value'] ?? NULL);
}
}
elseif (!$is_required) {
// The client didn't send this prop, fall back to the default.
$source = $this->getDefaultStaticPropSource($sdc_prop_name)->withValue(NULL);
}
else {
// The client didn't send this prop, but should.
// @todo perhaps we just be more accepting here and fall back anyway?
throw new \LogicException(sprintf('Required prop "%s" is missing from the client model.', $sdc_prop_name));
}
// 1. If the given static prop source matches the *current* field type
// configuration, use the configured widget.
// 2. Worst case: fall back to the default widget for this field type.
......@@ -408,7 +409,6 @@ abstract class GeneratedFieldExplicitInputUxComponentSourceBase extends Componen
}
assert(isset($component_schema['properties'][$sdc_prop_name]['title']));
$label = $component_schema['properties'][$sdc_prop_name]['title'];
$is_required = isset($component_schema['required']) && in_array($sdc_prop_name, $component_schema['required'], TRUE);
$widget = $source->getWidget($sdc_prop_name, $label, $field_widget_plugin_id);
$form[$sdc_prop_name] = $source->formTemporaryRemoveThisExclamationExclamationExclamation($widget, $sdc_prop_name, $is_required, $entity, $form, $form_state);
$form[$sdc_prop_name]['#disabled'] = $disabled;
......
<?php
declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Kernel;
use Drupal\Tests\experience_builder\TestSite\XBTestSetup;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversClass \Drupal\experience_builder\Form\ComponentInputsForm
* @covers \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\GeneratedFieldExplicitInputUxComponentSourceBase::buildConfigurationForm()
* @group experience_builder
*/
final class ComponentInputsFormTest extends ApiLayoutControllerTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->container->get('module_installer')->install(['system', 'xb_test_sdc']);
$this->container->get('theme_installer')->install(['stark']);
$this->container->get('config.factory')->getEditable('system.theme')->set('default', 'stark')->save();
(new XBTestSetup())->setup();
$this->setUpCurrentUser(permissions: ['access administration pages', 'administer themes']);
}
#[DataProvider('providerOptionalImages')]
public function testOptionalImageAndHeading(string $component, array $values_to_set, array $expected_form_xb_props): void {
$response = $this->parentRequest(Request::create('/xb/api/v0/config/component'))->getContent();
self::assertIsString($response);
// Fetch the client-side info.
// @see \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\GeneratedFieldExplicitInputUxComponentSourceBase::getClientSideInfo()
$client_side_info_prop_sources = json_decode($response, TRUE)[$component]['propSources'];
// Perform the same transformation the XB UI does in JavaScript to construct
// the `form_xb_props` request parameter expected by ComponentInputsForm.
// @see \Drupal\experience_builder\Form\ComponentInputsForm::buildForm()
// @see \Drupal\experience_builder\Plugin\ExperienceBuilder\ComponentSource\GeneratedFieldExplicitInputUxComponentSourceBase::buildConfigurationForm()
$actual_form_xb_props = [
// Used by client to render previews.
'resolved' => [],
// Used by client to provider server with metadata on how to construct an
// input UX.
'source' => [],
];
foreach ($client_side_info_prop_sources as $sdc_prop_name => $prop_source) {
$actual_form_xb_props['resolved'][$sdc_prop_name] = $prop_source['default_values']['resolved'] ?? [];
$actual_form_xb_props['source'][$sdc_prop_name]['value'] = $prop_source['default_values']['source'] ?? [];
$actual_form_xb_props['source'][$sdc_prop_name] += array_intersect_key($prop_source, array_flip([
'sourceType',
'sourceTypeSettings',
'expression',
]));
if (array_key_exists($sdc_prop_name, $values_to_set)) {
$actual_form_xb_props['resolved'][$sdc_prop_name] = $values_to_set[$sdc_prop_name]['resolved'];
$actual_form_xb_props['source'][$sdc_prop_name]['value'] = $values_to_set[$sdc_prop_name]['source'];
}
}
self::assertSame($expected_form_xb_props, $actual_form_xb_props);
$this->request(Request::create('/xb/api/v0/form/component-instance/node/1', 'PATCH', [
'form_xb_tree' => json_encode([
'nodeType' => 'component',
'slots' => [],
'type' => $component,
'uuid' => '5f18db31-fa2f-4f4e-a377-dc0c6a0b7dc4',
], JSON_THROW_ON_ERROR),
'form_xb_props' => json_encode($expected_form_xb_props, JSON_THROW_ON_ERROR),
'form_xb_selected' => '5f18db31-fa2f-4f4e-a377-dc0c6a0b7dc4',
]));
}
public function providerOptionalImages(): array {
return [
'sdc.xb_test_sdc.image-optional-without-example as in component list' => [
'sdc.xb_test_sdc.image-optional-without-example',
[],
[
'resolved' => [
'image' => [],
],
'source' => [
'image' => [
'value' => [],
'sourceType' => 'static:field_item:entity_reference',
'expression' => 'ℹ︎entity_reference␟{src↝entity␜␜entity:media:image␝field_media_image␞␟entity␜␜entity:file␝uri␞␟url,alt↝entity␜␜entity:media:image␝field_media_image␞␟alt,width↝entity␜␜entity:media:image␝field_media_image␞␟width,height↝entity␜␜entity:media:image␝field_media_image␞␟height}',
'sourceTypeSettings' => [
'storage' => ['target_type' => 'media'],
'instance' => [
'handler' => 'default:media',
'handler_settings' => [
'target_bundles' => ['image' => 'image'],
],
],
],
],
],
],
],
'image-optional-with-example-and-additional-prop as in component list' => [
'sdc.xb_test_sdc.image-optional-with-example-and-additional-prop',
[],
[
'resolved' => [
'heading' => [],
'image' => [
'src' => 'gracie.jpg',
'alt' => 'A good dog',
'width' => 601,
'height' => 402,
],
],
'source' => [
'heading' => [
'value' => [],
'sourceType' => 'static:field_item:string',
'expression' => 'ℹ︎string␟value',
],
'image' => [
'value' => [],
'sourceType' => 'static:field_item:entity_reference',
'expression' => 'ℹ︎entity_reference␟{src↝entity␜␜entity:media:image␝field_media_image␞␟entity␜␜entity:file␝uri␞␟url,alt↝entity␜␜entity:media:image␝field_media_image␞␟alt,width↝entity␜␜entity:media:image␝field_media_image␞␟width,height↝entity␜␜entity:media:image␝field_media_image␞␟height}',
'sourceTypeSettings' => [
'storage' => ['target_type' => 'media'],
'instance' => [
'handler' => 'default:media',
'handler_settings' => [
'target_bundles' => ['image' => 'image'],
],
],
],
],
],
],
],
'image-optional-with-example-and-additional-prop with heading set by user' => [
'sdc.xb_test_sdc.image-optional-with-example-and-additional-prop',
[
'heading' => [
'resolved' => 'test',
'source' => 'test',
],
],
[
'resolved' => [
'heading' => 'test',
'image' => [
'src' => 'gracie.jpg',
'alt' => 'A good dog',
'width' => 601,
'height' => 402,
],
],
'source' => [
'heading' => [
'value' => 'test',
'sourceType' => 'static:field_item:string',
'expression' => 'ℹ︎string␟value',
],
'image' => [
'value' => [],
'sourceType' => 'static:field_item:entity_reference',
'expression' => 'ℹ︎entity_reference␟{src↝entity␜␜entity:media:image␝field_media_image␞␟entity␜␜entity:file␝uri␞␟url,alt↝entity␜␜entity:media:image␝field_media_image␞␟alt,width↝entity␜␜entity:media:image␝field_media_image␞␟width,height↝entity␜␜entity:media:image␝field_media_image␞␟height}',
'sourceTypeSettings' => [
'storage' => ['target_type' => 'media'],
'instance' => [
'handler' => 'default:media',
'handler_settings' => [
'target_bundles' => ['image' => 'image'],
],
],
],
],
],
],
],
];
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment