graphql_compose-1.0.0-beta20/src/Form/SchemaForm.php
src/Form/SchemaForm.php
<?php
declare(strict_types=1);
namespace Drupal\graphql_compose\Form;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\Entity\BaseFieldOverride;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\graphql\Entity\ServerInterface;
use Drupal\graphql_compose\Utility\ComposeConfig;
use Drupal\graphql_compose\Utility\ComposeProviders;
use Drupal\graphql_compose\Plugin\GraphQLComposeEntityTypeManager;
use Drupal\graphql_compose\Plugin\GraphQLComposeFieldTypeManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
use function Symfony\Component\String\u;
/**
* Configure GraphQL Compose settings for this server.
*/
class SchemaForm extends ConfigFormBase {
/**
* Construct a new GraphQL Compose settings form.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* Drupal entity type manager.
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entityTypeBundleInfo
* Drupal entity type bundle service.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
* Drupal entity field manager.
* @param \Drupal\graphql_compose\Plugin\GraphQLComposeEntityTypeManager $gqlEntityTypeManager
* GraphQL Compose entity type manager.
* @param \Drupal\graphql_compose\Plugin\GraphQLComposeFieldTypeManager $gqlFieldTypeManager
* GraphQL Compose field type manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* Drupal module handler.
* @param \Drupal\graphql\Entity\ServerInterface $graphqlServer
* The GraphQL server being edited, if any.
*/
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected EntityTypeBundleInfoInterface $entityTypeBundleInfo,
protected EntityFieldManagerInterface $entityFieldManager,
protected GraphQLComposeEntityTypeManager $gqlEntityTypeManager,
protected GraphQLComposeFieldTypeManager $gqlFieldTypeManager,
protected ModuleHandlerInterface $moduleHandler,
protected ServerInterface $graphqlServer,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('entity_field.manager'),
$container->get('graphql_compose.entity_type_manager'),
$container->get('graphql_compose.field_type_manager'),
$container->get('module_handler'),
$container->get('current_route_match')->getParameter('graphql_server'),
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'graphql_compose_schema';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return [ComposeConfig::name()];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form_state->set('graphql_server', $this->graphqlServer);
/** @var \Drupal\graphql\Entity\ServerInterface[] $servers */
$servers = $this->entityTypeManager->getStorage('graphql_server')->loadByProperties([
'schema' => 'graphql_compose',
]);
if (empty($servers)) {
$this->messenger()->addError(
$this->t('No servers found. Please <a href="@url">create a server</a> using the %type schema.', [
'%type' => 'GraphQL Compose',
'@url' => Url::fromRoute('entity.graphql_server.create_form')->toString(TRUE)->getGeneratedUrl(),
])
);
}
$entity_definitions = $this->entityTypeManager->getDefinitions();
$entity_plugin_types = array_filter(
$this->gqlEntityTypeManager->getDefinitions(),
fn ($plugin) => empty($plugin['hidden'])
);
$entity_types = [];
foreach ($entity_plugin_types as $entity_plugin_type) {
$entity_type_id = $entity_plugin_type['id'];
if (array_key_exists($entity_type_id, $entity_definitions)) {
$entity_types[$entity_type_id] = $entity_definitions[$entity_type_id];
}
}
// Sort by entity label.
uasort($entity_types, fn (EntityTypeInterface $a, EntityTypeInterface $b) => strcmp(
(string) $a->getLabel(), (string) $b->getLabel()
));
$form['#tree'] = TRUE;
$form['#attached']['library'][] = 'graphql_compose/settings.admin';
$form['#attributes']['novalidate'] = 'novalidate';
$form['layout'] = [
'#type' => 'container',
'#name' => 'layout',
];
$form['layout']['entity_tabs'] = [
'#type' => 'vertical_tabs',
'#name' => 'entity-tabs',
];
// Loop every entity type.
foreach ($entity_types as $entity_type_id => $entity_type) {
// Visual containers.
$form['layout']['entity_tabs']['entity_type__' . $entity_type_id] = [
'#type' => 'details',
'#title' => $entity_type->getLabel(),
'#attributes' => [
'class' => ['entity-type-tab'],
],
'#group' => 'layout][entity_tabs',
];
$form['layout']['entity_tabs']['entity_type__' . $entity_type_id]['bundle_tabs'] = [
'#type' => 'vertical_tabs',
'#group' => 'layout][entity_tabs][entity_type__' . $entity_type_id,
];
if ($entity_type instanceof ConfigEntityTypeInterface) {
// Config entities like menu and image styles.
// We load all config entities of this type.
$config_entities = $this->entityTypeManager->getStorage($entity_type->id())->loadMultiple();
// Sort by label.
uasort($config_entities, fn ($a, $b) => strcmp($a->label(), $b->label()));
// Build entity "bundle" form without fields.
foreach ($config_entities as $config_entity) {
$this->buildEntityTypeBundle($form, $form_state, $entity_type, $config_entity);
}
}
else {
// Otherwise use bundle info.
if ($storage_type = $entity_type->getBundleEntityType()) {
$entity_bundles = $this->entityTypeManager->getStorage($storage_type)->loadMultiple();
// Sort by bundle label.
uasort($entity_bundles, fn (EntityInterface $a, EntityInterface $b) => strcmp(
(string) $a->label(), (string) $b->label()
));
}
else {
// Has no bundles, we'll just use the base entity type.
$entity_bundles = [$entity_type->id() => $entity_type];
}
// Build entity "bundle" with fields.
foreach ($entity_bundles as $bundle) {
$this->buildEntityTypeBundle($form, $form_state, $entity_type, $bundle);
if ($entity_type->entityClassImplements(FieldableEntityInterface::class)) {
$this->buildEntityTypeBundleFields($form, $form_state, $entity_type, $bundle->id());
}
}
}
}
return parent::buildForm($form, $form_state);
}
/**
* Build the config form for a "bundle" of an entity type.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
* @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\EntityTypeInterface $bundle
* The entity bundle.
*/
public function buildEntityTypeBundle(array &$form, FormStateInterface $form_state, EntityTypeInterface $entity_type, EntityInterface|EntityTypeInterface $bundle) {
$entity_type_id = $entity_type->id();
$bundle_id = $bundle->id();
$settings = ComposeConfig::get("entity_config.$entity_type_id.$bundle_id", []);
$bundle_form = [
'#type' => 'details',
'#title' => $bundle instanceof EntityTypeInterface ? $bundle->getLabel() : $bundle->label(),
'#name' => $entity_type_id . '_tabs_' . $bundle_id,
'#attributes' => [
'class' => ['entity-bundle-tab'],
],
'#group' => 'layout][entity_tabs][entity_type__' . $entity_type_id . '][bundle_tabs',
'#parents' => [
'settings', 'entity_config', $entity_type_id, $bundle_id,
],
];
$bundle_form['enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable GraphQL'),
'#default_value' => $settings['enabled'] ?? FALSE,
'#description' => $this->t('Expose this type via GraphQL.'),
'#weight' => -1,
'#attributes' => [
'class' => ['entity-bundle-enabled'],
],
'#element_validate' => ['::validateNullable'],
];
if ($entity_type instanceof ContentEntityTypeInterface) {
$bundle_form['query_load_enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable single query'),
'#default_value' => $settings['query_load_enabled'] ?? FALSE,
'#element_validate' => ['::validateNullable'],
'#description' => $this->t('Add a query to load this type by UUID.'),
];
}
// Create a wrapped instance of the entity.
$plugin = $this->gqlEntityTypeManager->getPluginInstance($entity_type_id);
if ($plugin && $entity_type instanceof ContentEntityTypeInterface) {
// phpcs:ignore
$wrap = \Drupal::service('graphql_compose.entity_type_wrapper')
->setEntityTypePlugin($plugin)
->setEntity($bundle);
$bundle_form['_attributes'] = [
'#type' => 'details',
'#weight' => 20,
'#title' => $this->t('Entity Attributes'),
'#parents' => [
'settings', 'entity_config', $entity_type_id, $bundle_id,
],
];
$bundle_form['_attributes']['type_sdl'] = [
'#type' => 'textfield',
'#title' => $this->t('Schema type'),
'#default_value' => $settings['type_sdl'] ?? NULL,
'#description' => $this->t('Leave blank to use the default value.'),
'#placeholder' => $wrap->getTypeSdl(),
'#element_validate' => ['::validateNullable', '::validateTypeSdl'],
'#maxlength' => 255,
'#size' => 20,
];
$bundle_form['_attributes']['description'] = [
'#type' => 'textarea',
'#title' => $this->t('Description'),
'#default_value' => $settings['description'] ?? NULL,
'#description' => $this->t('Leave blank to use the default value.'),
'#element_validate' => ['::validateNullable'],
'#placeholder' => $wrap->getDescription(),
'#maxlength' => 255,
];
}
// Allow other modules to add to this entity form.
ComposeProviders::invoke('graphql_compose_entity_type_form_alter', [
&$bundle_form,
$form_state,
$entity_type,
$bundle_id,
$settings,
]);
$form['settings'][$entity_type_id][$bundle_id] = $bundle_form;
}
/**
* Build fields for content entities. Eg Node types. Media types.
*
* @param array $form
* The form array.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
* @param string $bundle_id
* The entity bundle id.
*/
public function buildEntityTypeBundleFields(array &$form, FormStateInterface $form_state, EntityTypeInterface $entity_type, string $bundle_id) {
$entity_type_id = $entity_type->id();
$fields = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle_id);
$field_plugin_types = $this->gqlFieldTypeManager->getDefinitions();
// Hide base fields.
$fields = array_filter($fields, fn (FieldDefinitionInterface $field) => !$field instanceof BaseFieldDefinition);
$fields = array_filter($fields, fn (FieldDefinitionInterface $field) => !$field instanceof BaseFieldOverride);
// Hide fields that are not supported by GraphQL Compose.
$fields = array_filter($fields, fn (FieldDefinitionInterface $field) => array_key_exists($field->getType(), $field_plugin_types));
if (empty($fields)) {
return;
}
// Sort fields alphabetically by $field->getLabel() case insensitive.
uasort($fields, fn (FieldDefinitionInterface $a, FieldDefinitionInterface $b) => strcasecmp(
(string) $a->getLabel(), (string) $b->getLabel()
));
$form['settings'][$entity_type_id][$bundle_id]['_fields'] = [
'#type' => 'fieldset',
'#weight' => 20,
'#title' => 'Fields',
'#parents' => [
'settings', 'field_config', $entity_type_id, $bundle_id,
],
];
foreach ($fields as $field_name => $field) {
$settings = ComposeConfig::get("field_config.$entity_type_id.$bundle_id.$field_name", []);
$field_form = [
'#type' => 'details',
'#title' => $this->t('@label (@field_name)', [
'@label' => $field->getLabel(),
'@field_name' => $field_name,
]),
'#open' => $settings['enabled'] ?? FALSE,
];
// Allow users to enable and disable the field.
$field_form['enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable field'),
'#default_value' => $settings['enabled'] ?? FALSE,
'#weight' => -1,
'#element_validate' => ['::validateNullable'],
];
// Optionally override the required setting.
if (ComposeConfig::get('settings.field_required_override')) {
$default = isset($settings['required'])
? (int) $settings['required']
: '_default';
$field_form['required'] = [
'#type' => 'select',
'#title' => $this->t('Required'),
'#default_value' => $default,
'#options' => [
'_default' => $this->t('Default (@yesno)', [
'@yesno' => $field->isRequired() ? 'Yes' : 'No',
]),
"1" => $this->t('Yes'),
"0" => $this->t('No'),
],
'#element_validate' => ['::validateFalseable'],
];
}
// Hint at what the default value will be.
$placeholder = u($field->getName())
->trimPrefix('field_')
->camel()
->toString();
$field_form['name_sdl'] = [
'#type' => 'textfield',
'#title' => $this->t('Schema field name'),
'#default_value' => $settings['name_sdl'] ?? NULL,
'#placeholder' => $placeholder,
'#description' => $this->t('Leave blank to use automatically generated name.'),
'#element_validate' => ['::validateNullable', '::validateNameSdl'],
'#maxlength' => 255,
'#size' => 20,
'#weight' => 10,
];
// A sdl rename is required if field name starts with a number.
// https://www.drupal.org/project/graphql_compose/issues/3409260
if (preg_match('/^[0-9]/', $placeholder)) {
$field_form['name_sdl']['#element_validate'][] = '::validateNameSdlRequired';
$field_form['name_sdl']['#states']['required'] = [
':input[name="settings[field_config][' . $entity_type_id . '][' . $bundle_id . '][' . $field_name . '][enabled]"]' => ['checked' => TRUE],
];
}
// Allow other modules to modify the field form.
ComposeProviders::invoke('graphql_compose_field_type_form_alter', [
&$field_form,
$form_state,
$field,
$settings,
]);
$form['settings'][$entity_type_id][$bundle_id]['_fields'][$field_name] = $field_form;
}
}
/**
* Callback for type sdl validation.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $form
* The form.
*/
public static function validateTypeSdl(array &$element, FormStateInterface &$form_state, array $form): void {
$value = $form_state->getValue($element['#parents'], '');
$value = is_string($value) ? trim($value) : $value;
$enabled = NestedArray::getValue(
$form_state->getValues(),
[...array_slice($element['#parents'], 0, -1), 'enabled']
);
if ($enabled && $value && !preg_match('/^[A-Z]([A-Za-z0-9]+)?$/', $value)) {
$message = t('@type must start with a uppercase letter and contain only letters and numbers.', [
'@type' => $element['#title'] ?? 'Type',
]);
$form_state->setError($element, $message);
}
}
/**
* Callback for name sdl validation.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $form
* The form.
*/
public static function validateNameSdl(array &$element, FormStateInterface &$form_state, array $form): void {
$value = $form_state->getValue($element['#parents'], '');
$value = is_string($value) ? trim($value) : $value;
$enabled = NestedArray::getValue(
$form_state->getValues(),
[...array_slice($element['#parents'], 0, -1), 'enabled']
);
if ($enabled && $value && !preg_match('/^[a-z]([A-Za-z0-9]+)?$/', $value)) {
$message = t('@name must start with a lowercase letter and contain only letters and numbers.', [
'@name' => $element['#title'] ?? 'Field name',
]);
$form_state->setError($element, $message);
}
}
/**
* Callback for name sdl validation.
*
* This is helpful due to #states being client-side only.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $form
* The form.
*/
public static function validateNameSdlRequired(array &$element, FormStateInterface &$form_state, array $form): void {
$value = $form_state->getValue($element['#parents'], '');
$value = is_string($value) ? trim($value) : $value;
$enabled = NestedArray::getValue(
$form_state->getValues(),
[...array_slice($element['#parents'], 0, -1), 'enabled']
);
if ($enabled && empty($value)) {
$message = t('@name is required.', [
'@name' => $element['#title'] ?? 'Field name',
]);
$form_state->setError($element, $message);
}
}
/**
* Replace empty values with a null, which will be stripped from config.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $form
* The form.
*/
public static function validateNullable(array &$element, FormStateInterface &$form_state, array $form): void {
$value = $form_state->getValue($element['#parents'], '');
$value = is_string($value) ? trim($value) : $value;
if (empty($value) || $value === '_default') {
$form_state->setValueForElement($element, NULL);
}
}
/**
* Replace non false values with a null, which will be stripped from config.
*
* @param array $element
* The element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param array $form
* The form.
*/
public static function validateFalseable(array &$element, FormStateInterface &$form_state, array $form): void {
$value = $form_state->getValue($element['#parents'], '');
$value = is_string($value) ? trim($value) : $value;
if ($value === "" || $value === '_default') {
$form_state->setValueForElement($element, NULL);
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$entity_config = $form_state->getValue(['settings', 'entity_config'], []);
$field_config = $form_state->getValue(['settings', 'field_config'], []);
self::sortAndFilterSettings($entity_config);
self::sortAndFilterSettings($field_config);
$this->config(ComposeConfig::name())
->set('entity_config', $entity_config)
->set('field_config', $field_config)
->save();
_graphql_compose_cache_flush();
parent::submitForm($form, $form_state);
}
/**
* Recursively sort and filter settings.
*
* @param array $settings
* The array to sort and filter.
*/
public static function sortAndFilterSettings(array &$settings): void {
ksort($settings);
foreach ($settings as &$value) {
$value = is_string($value) ? trim($value) : $value;
if (is_array($value)) {
self::sortAndFilterSettings($value);
}
}
// Remove empty arrays and null values.
$settings = array_filter($settings, function ($value) {
return is_array($value) ? !empty($value) : !is_null($value);
});
}
}
