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

Issue #3463988 by tedbow, Wim Leers, longwave, lauriii: HTTP API: new...

Issue #3463988 by tedbow, Wim Leers, longwave, lauriii: HTTP API: new /xb/api/entity-form/{entity_type}/{entity}/{entity_form_mode} route to load form for editing entity fields (meta + non-meta)
parent 8b8e0e8b
No related branches found
No related tags found
1 merge request!187#3463988: HTTP API: new /xb/api/entity-form/{entity_type}/{entity}/{entity_form_mode} route to load form for editing entity fields (meta + non-meta)
Pipeline #262571 failed
......@@ -91,3 +91,21 @@ experience_builder.component_props_form:
parameters:
entity:
type: entity:{entity_type}
experience_builder.api.entity_form:
path: '/xb/api/entity-form/{entity_type}/{entity}/{entity_form_mode}'
defaults:
# It is not possible to use '_entity_form' as a default because form mode is not taken into account.
# When '_entity_form' is used as a default with 2 parts, for example 'node.default', the 2nd part is the form
# operation, not form mode.
# @see \Drupal\Core\Entity\Enhancer\EntityRouteEnhancer::enhanceEntityForm
# @see \Drupal\Core\Entity\HtmlEntityFormController::getFormObject
_controller: '\Drupal\experience_builder\Controller\EntityFormController::form'
_title: 'Entity Form'
entity_form_mode: default
requirements:
_permission: 'access administration pages'
options:
parameters:
entity:
type: entity:{entity_type}
......@@ -146,8 +146,70 @@ paths:
responses:
200:
description: Generated preview successfully
/xb/api/entity-form/{entityTypeId}/{id}:
description: 'Fetches the entity form with the "default" form mode'
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
responses:
200:
$ref: '#/components/responses/FormResponse'
/xb/api/entity-form/{entityTypeId}/{id}/{entityFormMode}:
description: 'Fetches the entity form with the specified form mode'
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
- name: entityFormMode
in: path
required: true
description: 'The entity form mode of the form to retrieve'
schema:
type: string
example: default
responses:
200:
$ref: '#/components/responses/FormResponse'
components:
parameters:
entityTypeId:
name: entityTypeId
in: path
required: true
description: 'The entity type ID of the form to retrieve'
schema:
type: string
example: node
id:
name: id
in: path
required: true
description: 'The entity ID of the form to retrieve'
schema:
type: integer
examples:
'first entity of this type':
value: 14
'another entity of this type':
value: 42
responses:
FormResponse:
description: 'The form'
content:
application/json:
examples:
'A form':
value:
html: "<form></form>"
schema:
type: object
required: [html]
properties:
html:
schema:
type: string
schemas:
Component:
title: Component
......
<?php
declare(strict_types=1);
namespace Drupal\experience_builder\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Entity\EntityDisplayBase;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Render\RendererInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class EntityFormController extends ControllerBase {
public function __construct(
private readonly RendererInterface $renderer,
) {}
public function form(string $entity_type, FieldableEntityInterface $entity, string $entity_form_mode): Response {
// The 'default' value sent to `\Drupal\Core\Entity\EntityTypeManagerInterface::getFormObject`
// is for 'operation' not form mode.
$form = $this->entityTypeManager()->getFormObject($entity_type, 'default');
$form->setEntity($entity);
assert($form instanceof ContentEntityForm);
// `EntityFormDisplay::collectRenderDisplay()` assumes that if the form mode is 'default'
// then $default_fallback should be TRUE. Otherwise, it will have a warning
// for an undefined variable.
// For all other form modes, the default fallback should be FALSE because the
// client is requesting a specific form mode it expects to be available.
$default_fallback = $entity_form_mode === 'default';
$form_display = EntityFormDisplay::collectRenderDisplay($entity, $entity_form_mode, $default_fallback);
// TRICKY: If a form display is returned with the EntityDisplayBase::CUSTOM_MODE then
// `EntityFormDisplay::collectRenderDisplay()` was not able to find a form
// display for the requested form mode and created runtime form display that
// is not saved in config. For our purpose we don't want this functionality.
// Since the client is specifically requesting a form mode it should be considered
// an error if that form mode is not found.
// We can't simply check `$form_display->getMode() !== $entity_form_mode`
// because the requested form mode could have altered by a hook and in that case we
// should respect that change.
// @see hook_ENTITY_TYPE_form_mode_alter()
// @see hook_entity_form_mode_alter()
if (!$form_display || $form_display->getMode() === EntityDisplayBase::CUSTOM_MODE) {
throw new \UnexpectedValueException(sprintf('The "%s" form display was not found', $entity_form_mode));
}
assert($form_display instanceof EntityFormDisplay);
$form_state = new FormState();
$form->setFormDisplay($form_display, $form_state);
$build = $this->formBuilder()->buildForm($form, $form_state);
$html = $this->renderer->renderRoot($build);
return new JsonResponse([
'html' => $html,
]);
}
}
......@@ -8,13 +8,12 @@ declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Functional;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Serialization\Yaml;
use Drupal\Core\TypedData\Plugin\DataType\Uri;
use Drupal\experience_builder\Plugin\DataType\ComponentPropsValues;
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\PropSource\AdaptedPropSource;
use Drupal\experience_builder\PropSource\DynamicPropSource;
use Drupal\experience_builder\PropSource\PropSourceBase;
......@@ -22,16 +21,11 @@ use Drupal\experience_builder\PropSource\StaticPropSource;
use Drupal\file\Entity\File;
use Drupal\file\Plugin\Field\FieldType\FileUriItem;
use Drupal\image\Entity\ImageStyle;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
/**
* @group experience_builder
*/
class EndToEndDemoIntegrationTest extends BrowserTestBase {
use TestFileCreationTrait;
class EndToEndDemoIntegrationTest extends FunctionalTestBase {
/**
* {@inheritdoc}
......@@ -53,51 +47,8 @@ class EndToEndDemoIntegrationTest extends BrowserTestBase {
*/
public function test(): void {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// The `thumbnail` image style already exists.
$this->assertInstanceOf(ImageStyle::class, ImageStyle::load('thumbnail'));
// Node 1 does not exist.
$this->assertNull(Node::load(1));
// Navigate to `/node/add/article` and press `Save`, do nothing else.
$this->drupalLogin($this->rootUser);
$this->drupalGet('node/add/article');
$assert_session->statusCodeEquals(200);
$page->pressButton('Save');
$this->assertStringEndsWith('node/add/article', $this->getSession()->getCurrentUrl());
// @todo For some reason, specifying `type: 'error'` fails: the expected HTML structure is different?! 🤯
$this->assertSession()->statusMessageContains('Title field is required.');
$this->assertSession()->statusMessageContains('Hero field is required.');
// Two entity fields are required: `Title` + `Hero`. Fill 'em, press `Save`.
$page->fillField('title[0][value]', 'The first entity using XB!');
$image_file = current($this->getTestFiles('image'));
// @phpstan-ignore-next-line
$image_file_uri = 'public://' . $image_file->name . ' with spaces.png';
$file_system = $this->container->get(FileSystemInterface::class);
assert($file_system instanceof FileSystemInterface);
// @phpstan-ignore-next-line
$file_system->move($image_file->uri, $image_file_uri, FileExists::Rename);
$image_path = $this->container->get('file_system')->realpath($image_file_uri);
$this->assertNotFalse($image_path);
$page->attachFileToField('files[field_hero_0]', $image_path);
$page->pressButton('Save');
$this->assertStringEndsWith('node/add/article', $this->getSession()->getCurrentUrl());
// Now that a file has been uploaded, we also need to specify `alt`.
$this->assertSession()->statusMessageContains('Alternative text field is required.');
$page->fillField('field_hero[0][alt]', 'A random image for testing purposes.');
$page->pressButton('Save');
// Success!
$this->assertStringEndsWith('node/1', $this->getSession()->getCurrentUrl());
$node = $this->createTestNode1();
// Assert the node has the expected values.
$node = Node::load(1);
// @phpstan-ignore-next-line
$this->assertInstanceOf(Node::class, $node);
$this->assertSame([
[
'value' => 'The first entity using XB!',
......@@ -120,6 +71,7 @@ class EndToEndDemoIntegrationTest extends BrowserTestBase {
// Assert 5 component instances are placed; they are the default value.
// @see config/optional/field.field.node.article.field_xb_demo.yml
assert($node->get('field_xb_demo')[0] instanceof ComponentTreeItem);
$tree = $node->get('field_xb_demo')[0]->get('tree');
$this->assertInstanceOf(ComponentTreeStructure::class, $tree);
// First, assert the stored JSON.
......@@ -318,6 +270,7 @@ class EndToEndDemoIntegrationTest extends BrowserTestBase {
],
], array_map($make_source_assertable, $props->getComponentPropsSources('dynamic-image-udf7d')));
// Prop source for component instance with UUID `dynamic-image-static-imageStyle-something7d`.
assert(ImageStyle::load('thumbnail') instanceof ImageStyle);
$this->assertEquals([
'image' => [
'source class' => AdaptedPropSource::class,
......
<?php
declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Functional;
use Drupal\user\Entity\User;
/**
* @coversDefaultClass \Drupal\experience_builder\Controller\EntityFormController
* @group experience_builder
*/
class EntityFormControllerTest extends FunctionalTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['experience_builder'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected $profile = 'standard';
/**
* @covers ::form
*/
public function testForm(): void {
$assert = $this->assertSession();
$this->createTestNode1();
$this->assertFormResponse('xb/api/entity-form/node/1/default', TRUE);
$this->assertFormResponse('xb/api/entity-form/node/1', TRUE);
$new_form_mode_path = 'xb/api/entity-form/node/1/mode2';
// Try to retrieve the form using the new form mode before it is created.
$this->drupalGet($new_form_mode_path);
$assert->statusCodeEquals(500);
$assert->pageTextContains('The "mode2" form display was not found');
$user = $this->drupalCreateUser(['administer display modes', 'administer node form display', 'access administration pages']);
$this->assertInstanceOf(User::class, $user);
$this->drupalLogin($user);
$this->drupalGet('admin/structure/display-modes/form/add/node');
$assert->statusCodeEquals(200);
$edit = [
'id' => 'mode2',
'label' => 'Mode 2',
'bundles_by_entity[article]' => 'article',
];
$this->submitForm($edit, 'Save');
$this->assertSession()->pageTextContains("Saved the Mode 2 form mode.");
// The menu element should not appear in the 'mode2' form mode.
$this->assertFormResponse($new_form_mode_path, FALSE);
}
private function assertFormResponse(string $path, bool $expected_menu_element): void {
$response = $this->drupalGet($path);
$this->assertSession()->statusCodeEquals(200);
$this->assertJson($response);
$decoded = json_decode($response, TRUE);
$this->assertSame(['html'], array_keys($decoded));
$html = $decoded['html'];
$this->assertStringStartsWith('<form class="node-article-form node-form" data-drupal-selector="node-article-form" enctype="multipart/form-data"', $html);
$menu_form_element_html_snippet = '<input data-drupal-selector="edit-menu-title"';
$expected_menu_element ?
$this->assertStringContainsString($menu_form_element_html_snippet, $html) :
$this->assertStringNotContainsString($menu_form_element_html_snippet, $html);
}
}
<?php
declare(strict_types=1);
namespace Drupal\Tests\experience_builder\Functional;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\image\Entity\ImageStyle;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
abstract class FunctionalTestBase extends BrowserTestBase {
use TestFileCreationTrait;
protected function createTestNode1(): Node {
$assert_session = $this->assertSession();
$page = $this->getSession()->getPage();
// The `thumbnail` image style already exists.
$this->assertInstanceOf(ImageStyle::class, ImageStyle::load('thumbnail'));
// Node 1 does not exist.
$this->assertNull(Node::load(1));
// Navigate to `/node/add/article` and press `Save`, do nothing else.
$this->drupalLogin($this->rootUser);
$this->drupalGet('node/add/article');
$assert_session->statusCodeEquals(200);
$page->pressButton('Save');
$this->assertStringEndsWith('node/add/article', $this->getSession()->getCurrentUrl());
// @todo For some reason, specifying `type: 'error'` fails: the expected HTML structure is different?! 🤯
$this->assertSession()->statusMessageContains('Title field is required.');
$this->assertSession()->statusMessageContains('Hero field is required.');
// Two entity fields are required: `Title` + `Hero`. Fill 'em, press `Save`.
$page->fillField('title[0][value]', 'The first entity using XB!');
$image_file = current($this->getTestFiles('image'));
// @phpstan-ignore-next-line
$image_file_uri = 'public://' . $image_file->name . ' with spaces.png';
$file_system = $this->container->get(FileSystemInterface::class);
assert($file_system instanceof FileSystemInterface);
// @phpstan-ignore-next-line
$file_system->move($image_file->uri, $image_file_uri, FileExists::Rename);
$image_path = $this->container->get('file_system')->realpath($image_file_uri);
$this->assertNotFalse($image_path);
$page->attachFileToField('files[field_hero_0]', $image_path);
$page->pressButton('Save');
$this->assertStringEndsWith('node/add/article', $this->getSession()->getCurrentUrl());
// Now that a file has been uploaded, we also need to specify `alt`.
$this->assertSession()->statusMessageContains('Alternative text field is required.');
$page->fillField('field_hero[0][alt]', 'A random image for testing purposes.');
$page->pressButton('Save');
// Success!
$this->assertStringEndsWith('node/1', $this->getSession()->getCurrentUrl());
$node = Node::load(1);
// @phpstan-ignore-next-line
$this->assertInstanceOf(Node::class, $node);
return $node;
}
}
......@@ -6,15 +6,13 @@ namespace Drupal\Tests\experience_builder\Functional;
use Drupal\experience_builder\Plugin\DataType\ComponentPropsValues;
use Drupal\experience_builder\Plugin\DataType\ComponentTreeStructure;
use Drupal\experience_builder\Plugin\Field\FieldType\ComponentTreeItem;
use Drupal\image\Entity\ImageStyle;
use Drupal\node\Entity\Node;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait;
use Drupal\Tests\TestFileCreationTrait;
class TranslationTest extends BrowserTestBase {
class TranslationTest extends FunctionalTestBase {
use TestFileCreationTrait;
use ContentTranslationTestTrait;
/**
......@@ -170,40 +168,7 @@ class TranslationTest extends BrowserTestBase {
* The default translation of the node.
*/
protected function createXbNodeWithTranslation(): Node {
$page = $this->getSession()->getPage();
$assert_session = $this->assertSession();
// Node 1 does not exist.
$this->assertNull(Node::load(1));
$this->drupalGet('node/add/article');
$assert_session->statusCodeEquals(200);
$page->pressButton('Save');
$this->assertStringEndsWith('node/add/article',
$this->getSession()->getCurrentUrl());
// @todo For some reason, specifying `type: 'error'` fails: the expected HTML structure is different?! 🤯
$this->assertSession()->statusMessageContains('Title field is required.');
$this->assertSession()->statusMessageContains('Hero field is required.');
// Two entity fields are required: `Title` + `Hero`. Fill 'em, press `Save`.
$page->fillField('title[0][value]', 'The first entity using XB!');
$image_file = current($this->getTestFiles('image'));
// @phpstan-ignore-next-line
$image_path = $this->container->get('file_system')->realpath($image_file->uri);
$this->assertNotFalse($image_path);
$page->attachFileToField('files[field_hero_0]', $image_path);
$page->pressButton('Save');
// Now that a file has been uploaded, we also need to specify `alt`.
$this->assertSession()
->statusMessageContains('Alternative text field is required.');
$page->fillField('field_hero[0][alt]',
'A random image for testing purposes.');
$page->pressButton('Save');
// Success!
$this->assertStringEndsWith('node/1', $this->getSession()->getCurrentUrl());
$node = Node::load(1);
// @phpstan-ignore-next-line
$this->assertInstanceOf(Node::class, $node);
$node = $this->createTestNode1();
// Create a translation from the original English node.
$translation = $node->addTranslation('fr');
$this->assertInstanceOf(Node::class, $translation);
......@@ -212,6 +177,7 @@ class TranslationTest extends BrowserTestBase {
$translation->title = 'The French title';
$translation->save();
$translation = $node->getTranslation('fr');
assert($node->get('field_xb_demo')[0] instanceof ComponentTreeItem);
$props = $node->get('field_xb_demo')[0]->get('props');
$this->assertInstanceOf(ComponentPropsValues::class, $props);
$original_props_value = $props->getValue();
......@@ -219,6 +185,7 @@ class TranslationTest extends BrowserTestBase {
// In both the Symmetric and Asymmetric translation cases, the `props` field
// is translatable and this should only change the translation.
$french_prop = str_replace('hello, world!', 'bonjour, monde!', $original_props_value);
assert($translation->get('field_xb_demo')[0] instanceof ComponentTreeItem);
$translation->get('field_xb_demo')[0]->set('props', $french_prop);
$translation->save();
......
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