diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 951593e052..d25d9b975a 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -17,4 +17,4 @@ composer_version: "" disable_settings_management: true web_environment: - UNISH_DB_URL=mysql://root:root@db - - PHP_EXTENSIONS_DISABLE=uploadprogress + - DRUSH_OPTIONS_URI=$DDEV_PRIMARY_URL diff --git a/.ddev/web-build/Dockerfile b/.ddev/web-build/Dockerfile new file mode 100644 index 0000000000..48d9a8a222 --- /dev/null +++ b/.ddev/web-build/Dockerfile @@ -0,0 +1,4 @@ +# See https://stackoverflow.com/a/67092243/265501 +ARG BASE_IMAGE +FROM $BASE_IMAGE +RUN echo 'export PATH="$PATH:/var/www/html"' > /etc/bashrc/commandline-addons.bashrc diff --git a/src/Commands/LegacyCommands.php b/src/Commands/LegacyCommands.php index 9096e76492..3d382552ed 100644 --- a/src/Commands/LegacyCommands.php +++ b/src/Commands/LegacyCommands.php @@ -129,18 +129,6 @@ public function download() { } - /** - * field-create has been removed. Please try `generate field` command. - * - * @command field:create - * @aliases field-create - * @hidden - * @obsolete - */ - public function field() - { - } - /** * core:execute has been removed. Please try `site:ssh` command. * diff --git a/src/Commands/ValidatorsCommands.php b/src/Commands/ValidatorsCommands.php index daca150bb4..f6be086645 100644 --- a/src/Commands/ValidatorsCommands.php +++ b/src/Commands/ValidatorsCommands.php @@ -12,7 +12,6 @@ */ class ValidatorsCommands { - /** * Validate that passed entity names are valid. * @see \Drush\Commands\core\ViewsCommands::execute for an example. diff --git a/src/Drupal/Commands/core/FieldCreateCommands.php b/src/Drupal/Commands/core/FieldCreateCommands.php new file mode 100644 index 0000000000..706dc61c1a --- /dev/null +++ b/src/Drupal/Commands/core/FieldCreateCommands.php @@ -0,0 +1,726 @@ +fieldTypePluginManager = $fieldTypePluginManager; + $this->widgetPluginManager = $widgetPluginManager; + $this->selectionPluginManager = $selectionPluginManager; + $this->entityTypeManager = $entityTypeManager; + $this->entityTypeBundleInfo = $entityTypeBundleInfo; + $this->moduleHandler = $moduleHandler; + $this->entityFieldManager = $entityFieldManager; + } + + public function setContentTranslationManager(ContentTranslationManagerInterface $manager): void + { + $this->contentTranslationManager = $manager; + } + + /** + * Create a new field + * + * @command field:create + * @aliases field-create,fc + * + * @param string $entityType + * The machine name of the entity type + * @param string $bundle + * The machine name of the bundle + * + * @option field-name + * A unique machine-readable name containing letters, numbers, and underscores. + * @option field-label + * The field label + * @option field-description + * Instructions to present to the user below this field on the editing form. + * @option field-type + * The field type + * @option field-widget + * The field widget + * @option is-required + * Whether the field is required + * @option is-translatable + * Whether the field is translatable + * @option cardinality + * The allowed number of values + * @option target-type + * The target entity type. Only necessary for entity reference fields. + * @option target-bundle + * The target bundle(s). Only necessary for entity reference fields. + * + * @option existing + * Re-use an existing field. + * @option existing-field-name + * The name of an existing field you want to re-use. Only used in non-interactive context. + * @option show-machine-names + * Show machine names instead of labels in option lists. + * + * @usage drush field:create + * Create a field by answering the prompts. + * @usage drush field-create taxonomy_term tag + * Create a field and fill in the remaining information through prompts. + * @usage drush field-create taxonomy_term tag --field-name=field_tag_label --field-label=Label --field-type=string --field-widget=string_textfield --is-required=1 --cardinality=2 + * Create a field in a completely non-interactive way. + * + * @see \Drupal\field_ui\Form\FieldConfigEditForm + * @see \Drupal\field_ui\Form\FieldStorageConfigEditForm + */ + public function create(string $entityType, ?string $bundle = null, array $options = [ + 'field-name' => InputOption::VALUE_REQUIRED, + 'field-label' => InputOption::VALUE_REQUIRED, + 'field-description' => InputOption::VALUE_OPTIONAL, + 'field-type' => InputOption::VALUE_REQUIRED, + 'field-widget' => InputOption::VALUE_REQUIRED, + 'is-required' => InputOption::VALUE_OPTIONAL, + 'is-translatable' => InputOption::VALUE_OPTIONAL, + 'cardinality' => InputOption::VALUE_REQUIRED, + 'target-type' => InputOption::VALUE_OPTIONAL, + 'target-bundle' => InputOption::VALUE_OPTIONAL, + 'show-machine-names' => InputOption::VALUE_OPTIONAL, + 'existing-field-name' => InputOption::VALUE_OPTIONAL, + 'existing' => false, + ]): void + { + $this->validateEntityType($entityType); + + $this->input->setArgument('bundle', $bundle = $bundle ?? $this->askBundle()); + $this->validateBundle($entityType, $bundle); + + if ($this->input->getOption('existing') || $this->input->getOption('existing-field-name')) { + $this->ensureOption('existing-field-name', [$this, 'askExistingFieldName'], false); + + if (!$fieldName = $this->input->getOption('existing-field-name')) { + throw new \InvalidArgumentException( + t('There are no existing fields that can be added.') + ); + } + + if (!$this->fieldStorageExists($fieldName, $entityType)) { + throw new \InvalidArgumentException( + t("Field storage with name ':fieldName' does not yet exist. Call this command without the --existing option first.", [ + ':fieldName' => $fieldName, + ]) + ); + } + + $fieldStorage = $this->entityFieldManager->getFieldStorageDefinitions($entityType)[$fieldName]; + + if ($this->fieldExists($fieldName, $entityType, $bundle)) { + throw new \InvalidArgumentException( + t("Field with name ':fieldName' already exists on bundle ':bundle'.", [ + ':fieldName' => $fieldName, + ':bundle' => $bundle, + ]) + ); + } + + $this->input->setOption('field-name', $fieldName); + $this->input->setOption('field-type', $fieldStorage->getType()); + $this->input->setOption('target-type', $fieldStorage->getSetting('target_type')); + + $this->ensureOption('field-label', [$this, 'askFieldLabel'], true); + $this->ensureOption('field-description', [$this, 'askFieldDescription'], false); + $this->ensureOption('field-widget', [$this, 'askFieldWidget'], true); + $this->ensureOption('is-required', [$this, 'askRequired'], false); + $this->ensureOption('is-translatable', [$this, 'askTranslatable'], false); + } else { + $this->ensureOption('field-label', [$this, 'askFieldLabel'], true); + $this->ensureOption('field-name', [$this, 'askFieldName'], true); + + $fieldName = $this->input->getOption('field-name'); + if ($this->fieldStorageExists($fieldName, $entityType)) { + throw new \InvalidArgumentException( + t("Field storage with name ':fieldName' already exists. Call this command with the --existing option to add an existing field to a bundle.", [ + ':fieldName' => $fieldName, + ]) + ); + } + + $this->ensureOption('field-description', [$this, 'askFieldDescription'], false); + $this->ensureOption('field-type', [$this, 'askFieldType'], true); + $this->ensureOption('field-widget', [$this, 'askFieldWidget'], true); + $this->ensureOption('is-required', [$this, 'askRequired'], false); + $this->ensureOption('is-translatable', [$this, 'askTranslatable'], false); + $this->ensureOption('cardinality', [$this, 'askCardinality'], true); + + if ($this->input->getOption('field-type') === 'entity_reference') { + $this->ensureOption('target-type', [$this, 'askReferencedEntityType'], true); + } + + $this->createFieldStorage(); + } + + // Command files may set additional options as desired. + $handlers = $this->getCustomEventHandlers('field-create-set-options'); + foreach ($handlers as $handler) { + $handler($this->input); + } + + $field = $this->createField(); + $this->createFieldDisplay('form'); + $this->createFieldDisplay('view'); + + $this->logResult($field); + } + + protected function validateEntityType(string $entityTypeId): void + { + if (!$this->entityTypeManager->hasDefinition($entityTypeId)) { + throw new \InvalidArgumentException( + t("Entity type with id ':entityType' does not exist.", [':entityType' => $entityTypeId]) + ); + } + } + + protected function validateBundle(string $entityTypeId, string $bundle): void + { + if (!$entityTypeDefinition = $this->entityTypeManager->getDefinition($entityTypeId)) { + return; + } + + $bundleEntityType = $entityTypeDefinition->getBundleEntityType(); + + if ($bundleEntityType === null && $bundle === $entityTypeId) { + return; + } + + $bundleDefinition = $this->entityTypeManager + ->getStorage($bundleEntityType) + ->load($bundle); + + if (!$bundleDefinition) { + throw new \InvalidArgumentException( + t("Bundle ':bundle' does not exist on entity type with id ':entityType'.", [ + ':bundle' => $bundle, + ':entityType' => $entityTypeId, + ]) + ); + } + } + + protected function askExistingFieldName(): ?string + { + $entityType = $this->input->getArgument('entityType'); + $bundle = $this->input->getArgument('bundle'); + $choices = $this->getExistingFieldStorageOptions($entityType, $bundle); + + if (empty($choices)) { + return null; + } + + return $this->io()->choice('Choose an existing field', $choices); + } + + protected function askFieldName(): string + { + $entityType = $this->input->getArgument('entityType'); + $bundle = $this->input->getArgument('bundle'); + $fieldLabel = $this->input->getOption('field-label'); + $fieldName = null; + $machineName = null; + + if (!empty($fieldLabel)) { + $machineName = $this->generateFieldName($fieldLabel, $bundle); + } + + while (!$fieldName) { + $answer = $this->io()->ask('Field name', $machineName, [static::class, 'validateRequired']); + + if (!preg_match('/^[_a-z]+[_a-z0-9]*$/', $answer)) { + $this->logger()->error('Only lowercase alphanumeric characters and underscores are allowed, and only lowercase letters and underscore are allowed as the first character.'); + continue; + } + + if (strlen($answer) > 32) { + $this->logger()->error('Field name must not be longer than 32 characters.'); + continue; + } + + if ($this->fieldStorageExists($answer, $entityType)) { + $this->logger()->error('A field with this name already exists.'); + continue; + } + + $fieldName = $answer; + } + + return $fieldName; + } + + protected function askFieldLabel(): string + { + return $this->io()->ask('Field label', null, [static::class, 'validateRequired']); + } + + protected function askFieldDescription(): ?string + { + return $this->io()->ask('Field description'); + } + + protected function askFieldType(): string + { + $definitions = $this->fieldTypePluginManager->getDefinitions(); + $choices = []; + + foreach ($definitions as $definition) { + $label = $this->input->getOption('show-machine-names') ? $definition['id'] : $definition['label']->render(); + $choices[$definition['id']] = $label; + } + + return $this->io()->choice('Field type', $choices); + } + + protected function askFieldWidget(): string + { + $formDisplay = $this->getEntityDisplay('form'); + + if ($formDisplay instanceof EntityFormDisplayInterface) { + $component = $formDisplay->getComponent($this->input->getOption('field-name')); + + if (isset($component['type'])) { + return $component['type']; + } + } + + $choices = []; + $fieldType = $this->input->getOption('field-type'); + $widgets = $this->widgetPluginManager->getOptions($fieldType); + + foreach ($widgets as $name => $label) { + $label = $this->input->getOption('show-machine-names') ? $name : $label->render(); + $choices[$name] = $label; + } + + return $this->io()->choice('Field widget', $choices); + } + + protected function askRequired(): bool + { + return $this->io()->confirm('Required', false); + } + + protected function askTranslatable(): bool + { + if (!$this->hasContentTranslation()) { + return false; + } + + return $this->io()->confirm('Translatable', false); + } + + protected function askBundle(): ?string + { + $entityTypeId = $this->input->getArgument('entityType'); + $entityTypeDefinition = $this->entityTypeManager->getDefinition($entityTypeId); + $bundleEntityType = $entityTypeDefinition->getBundleEntityType(); + $bundleInfo = $this->entityTypeBundleInfo->getBundleInfo($entityTypeId); + $choices = []; + + // If the entity type has one fixed bundle (eg. user), return it. + if ($bundleEntityType === null && count($bundleInfo) === 1) { + return key($bundleInfo); + } + + // If the entity type doesn't have bundles, return null + // TODO Find an example + if ($bundleEntityType === null && count($bundleInfo) === 0) { + return null; + } + + // If the entity type can have multiple bundles but it doesn't have any, throw an error + if ($bundleEntityType !== null && count($bundleInfo) === 0) { + throw new \InvalidArgumentException( + t("Entity type with id ':entityType' does not have any bundles.", [':entityType' => $entityTypeId]) + ); + } + + foreach ($bundleInfo as $bundle => $data) { + $label = $this->input->getOption('show-machine-names') ? $bundle : $data['label']; + $choices[$bundle] = $label; + } + + if (!$this->input->isInteractive() || !$answer = $this->io()->choice('Bundle', $choices)) { + throw new \InvalidArgumentException(t('The bundle argument is required.')); + } + + return $answer; + } + + protected function askCardinality(): int + { + $fieldType = $this->input->getOption('field-type'); + $definition = $this->fieldTypePluginManager->getDefinition($fieldType); + + // Some field types choose to enforce a fixed cardinality. + if (isset($definition['cardinality'])) { + return $definition['cardinality']; + } + + $choices = ['Limited', 'Unlimited']; + $cardinality = $this->io()->choice( + 'Allowed number of values', + array_combine($choices, $choices), + 1 + ); + + $limit = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; + while ($cardinality === 'Limited' && $limit < 1) { + $limit = (int) $this->io()->ask('Allowed number of values', 1); + } + + return $limit; + } + + protected function askReferencedEntityType(): string + { + $definitions = $this->entityTypeManager->getDefinitions(); + $choices = []; + + foreach ($definitions as $name => $definition) { + $label = $this->input->getOption('show-machine-names') + ? $name + : sprintf('%s: %s', $definition->getGroupLabel()->render(), $definition->getLabel()); + $choices[$name] = $label; + } + + return $this->io()->choice('Referenced entity type', $choices); + } + + protected function askReferencedBundles(FieldDefinitionInterface $fieldDefinition): array + { + $choices = []; + $bundleInfo = $this->entityTypeBundleInfo->getBundleInfo( + $fieldDefinition->getFieldStorageDefinition()->getSetting('target_type') + ); + + if (empty($bundleInfo)) { + return []; + } + + foreach ($bundleInfo as $bundle => $info) { + $label = $this->input->getOption('show-machine-names') ? $bundle : $info['label']; + $choices[$bundle] = $label; + } + + $question = (new ChoiceQuestion('Referenced bundles', $choices)) + ->setMultiselect(true); + + return $this->io()->askQuestion($question) ?: []; + } + + protected function createField(): FieldConfigInterface + { + $values = [ + 'field_name' => $this->input->getOption('field-name'), + 'entity_type' => $this->input->getArgument('entityType'), + 'bundle' => $this->input->getArgument('bundle'), + 'translatable' => $this->input->getOption('is-translatable'), + 'required' => $this->input->getOption('is-required'), + 'field_type' => $this->input->getOption('field-type'), + 'description' => $this->input->getOption('field-description') ?? '', + 'label' => $this->input->getOption('field-label'), + ]; + + // Command files may customize $values as desired. + $handlers = $this->getCustomEventHandlers('field-create-field-config'); + foreach ($handlers as $handler) { + $values = $handler($values, $this->input); + } + + $field = $this->entityTypeManager + ->getStorage('field_config') + ->create($values); + + if ($this->input->getOption('field-type') === 'entity_reference') { + $targetType = $this->input->getOption('target-type'); + $targetTypeDefinition = $this->entityTypeManager->getDefinition($targetType); + // For the 'target_bundles' setting, a NULL value is equivalent to "allow + // entities from any bundle to be referenced" and an empty array value is + // equivalent to "no entities from any bundle can be referenced". + $targetBundles = null; + + if ($targetTypeDefinition->hasKey('bundle')) { + if ($referencedBundle = $this->input->getOption('target-bundle')) { + $referencedBundles = [$referencedBundle]; + } else { + $referencedBundles = $this->askReferencedBundles($field); + } + + if (!empty($referencedBundles)) { + $targetBundles = array_combine($referencedBundles, $referencedBundles); + } + } + + $settings = $field->getSetting('handler_settings') ?? []; + $settings['target_bundles'] = $targetBundles; + $field->setSetting('handler_settings', $settings); + } + + $field->save(); + + return $field; + } + + protected function createFieldStorage(): FieldStorageConfigInterface + { + $values = [ + 'field_name' => $this->input->getOption('field-name'), + 'entity_type' => $this->input->getArgument('entityType'), + 'type' => $this->input->getOption('field-type'), + 'cardinality' => $this->input->getOption('cardinality'), + 'translatable' => true, + ]; + + if ($targetType = $this->input->getOption('target-type')) { + $values['settings']['target_type'] = $targetType; + } + + // Command files may customize $values as desired. + $handlers = $this->getCustomEventHandlers('field-create-field-storage'); + foreach ($handlers as $handler) { + $handler($values); + } + + /** @var FieldStorageConfigInterface $fieldStorage */ + $fieldStorage = $this->entityTypeManager + ->getStorage('field_storage_config') + ->create($values); + + $fieldStorage->save(); + + return $fieldStorage; + } + + protected function createFieldDisplay(string $context): void + { + $entityType = $this->input->getArgument('entityType'); + $bundle = $this->input->getArgument('bundle'); + $fieldName = $this->input->getOption('field-name'); + $fieldWidget = $this->input->getOption('field-widget'); + $values = []; + + if ($fieldWidget && $context === 'form') { + $values['type'] = $fieldWidget; + } + + // Command files may customize $values as desired. + $handlers = $this->getCustomEventHandlers(sprintf('field-create-%s-display', $context)); + foreach ($handlers as $handler) { + $handler($values); + } + + $storage = $this->getEntityDisplay($context); + + if (!$storage instanceof EntityDisplayInterface) { + $this->logger()->info( + sprintf("'%s' display storage not found for %s type '%s', creating now.", $context, $entityType, $bundle) + ); + + $storage = $this->entityTypeManager + ->getStorage(sprintf('entity_%s_display', $context)) + ->create([ + 'id' => sprintf('%s.%s.default', $entityType, $bundle), + 'targetEntityType' => $entityType, + 'bundle' => $bundle, + 'mode' => 'default', + 'status' => true, + ]); + + $storage->save(); + } + + $storage->setComponent($fieldName, $values)->save(); + } + + protected function getEntityDisplay(string $context): ?EntityDisplayInterface + { + $entityType = $this->input->getArgument('entityType'); + $bundle = $this->input->getArgument('bundle'); + + return $this->entityTypeManager + ->getStorage(sprintf('entity_%s_display', $context)) + ->load(sprintf('%s.%s.default', $entityType, $bundle)); + } + + protected function logResult(FieldConfigInterface $field): void + { + $this->logger()->success( + sprintf( + "Successfully created field '%s' on %s type with bundle '%s'", + $field->get('field_name'), + $field->get('entity_type'), + $field->get('bundle') + ) + ); + + /** @var EntityTypeInterface $entityType */ + $entityType = $this->entityTypeManager->getDefinition($field->get('entity_type')); + + $routeName = sprintf('entity.field_config.%s_field_edit_form', $entityType->id()); + $routeParams = [ + 'field_config' => $field->id(), + $entityType->getBundleEntityType() => $field->get('bundle'), + ]; + + if ($this->moduleHandler->moduleExists('field_ui')) { + $this->logger()->success( + 'Further customisation can be done at the following url:' + . PHP_EOL + . Url::fromRoute($routeName, $routeParams) + ->setAbsolute(true) + ->toString() + ); + } + } + + protected function generateFieldName(string $source, string $bundle): string + { + // Only lowercase alphanumeric characters and underscores + $machineName = preg_replace('/[^_a-z0-9]/i', '_', $source); + // Only lowercase letters and underscores as the first character + $machineName = preg_replace('/^[^_a-z]/i', '_', $machineName); + // Maximum one subsequent underscore + $machineName = preg_replace('/_+/', '_', $machineName); + // Only lowercase + $machineName = strtolower($machineName); + // Add the prefix + $machineName = sprintf('field_%s_%s', $bundle, $machineName); + // Maximum 32 characters + $machineName = substr($machineName, 0, 32); + + return $machineName; + } + + protected function fieldExists(string $fieldName, string $entityType, string $bundle): bool + { + $fieldDefinitions = $this->entityFieldManager->getFieldDefinitions($entityType, $bundle); + + return isset($fieldDefinitions[$fieldName]); + } + + protected function fieldStorageExists(string $fieldName, string $entityType): bool + { + $fieldStorageDefinitions = $this->entityFieldManager->getFieldStorageDefinitions($entityType); + + return isset($fieldStorageDefinitions[$fieldName]); + } + + protected function getExistingFieldStorageOptions(string $entityType, string $bundle): array + { + $fieldTypes = $this->fieldTypePluginManager->getDefinitions(); + $options = []; + + foreach ($this->entityFieldManager->getFieldStorageDefinitions($entityType) as $fieldName => $fieldStorage) { + // Do not show: + // - non-configurable field storages, + // - locked field storages, + // - field storages that should not be added via user interface, + // - field storages that already have a field in the bundle. + $fieldType = $fieldStorage->getType(); + $label = $this->input->getOption('show-machine-names') + ? $fieldTypes[$fieldType]['id'] + : $fieldTypes[$fieldType]['label']; + + if ($fieldStorage instanceof FieldStorageConfigInterface + && !$fieldStorage->isLocked() + && empty($fieldTypes[$fieldType]['no_ui']) + && !in_array($bundle, $fieldStorage->getBundles(), true) + ) { + $options[$fieldName] = sprintf('%s (%s)', $fieldName, $label); + } + } + + asort($options); + + return $options; + } + + protected function hasContentTranslation(): bool + { + $entityType = $this->input->getArgument('entityType'); + $bundle = $this->input->getArgument('bundle'); + + return $this->moduleHandler->moduleExists('content_translation') + && $this->contentTranslationManager->isEnabled($entityType, $bundle); + } + + protected function ensureOption(string $name, callable $asker, bool $required): void + { + $value = $this->input->getOption($name); + + if ($value === null && $this->input->isInteractive()) { + $value = $asker(); + } + + if ($required && $value === null) { + throw new \InvalidArgumentException(dt('The %optionName option is required.', [ + '%optionName' => $name, + ])); + } + + $this->input->setOption($name, $value); + } + + public static function validateRequired(?string $value): string + { + // FALSE is not considered as empty value because question helper use + // it as negative answer on confirmation questions. + if ($value === null || $value === '') { + throw new \UnexpectedValueException('This value is required.'); + } + + return $value; + } +} diff --git a/src/Drupal/Commands/core/LinkHooks.php b/src/Drupal/Commands/core/LinkHooks.php new file mode 100644 index 0000000000..646e28e2e8 --- /dev/null +++ b/src/Drupal/Commands/core/LinkHooks.php @@ -0,0 +1,103 @@ +moduleHandler = $moduleHandler; + } + + /** @hook option field:create */ + public function hookOption(Command $command, AnnotationData $annotationData): void + { + if (!$this->isInstalled()) { + return; + } + + $command->addOption( + 'link-type', + '', + InputOption::VALUE_REQUIRED, + 'Allowed link type.' + ); + + $command->addOption( + 'allow-link-text', + '', + InputOption::VALUE_REQUIRED, + 'Allow link text.' + ); + } + + /** @hook on-event field-create-set-options */ + public function hookSetOptions(InputInterface $input): void + { + if (!$this->isInstalled() + || $input->getOption('field-type') !== 'link' + ) { + return; + } + + $input->setOption( + 'link-type', + $this->input->getOption('link-type') ?? $this->askLinkType() + ); + + $input->setOption( + 'allow-link-text', + $this->input->getOption('allow-link-text') ?? $this->askAllowLinkText() + ); + } + + /** @hook on-event field-create-field-config */ + public function hookFieldConfig(array $values, InputInterface $input): array + { + if (!$this->isInstalled() + || $values['field_type'] !== 'link' + ) { + return $values; + } + + $values['settings']['title'] = $input->getOption('allow-link-text'); + $values['settings']['link_type'] = $input->getOption('link-type'); + + return $values; + } + + protected function askLinkType(): int + { + return $this->io()->choice('Allowed link type', [ + LinkItemInterface::LINK_INTERNAL => (string) t('Internal links only'), + LinkItemInterface::LINK_EXTERNAL => (string) t('External links only'), + LinkItemInterface::LINK_GENERIC => (string) t('Both internal and external links'), + ]); + } + + protected function askAllowLinkText(): int + { + return $this->io()->choice('Allow link text', [ + DRUPAL_DISABLED => (string) t('Disabled'), + DRUPAL_OPTIONAL => (string) t('Optional'), + DRUPAL_REQUIRED => (string) t('Required'), + ]); + } + + protected function isInstalled(): bool + { + return $this->moduleHandler->moduleExists('link'); + } +} diff --git a/src/Drupal/Commands/core/drush.services.yml b/src/Drupal/Commands/core/drush.services.yml index 6dca5b9d28..e70ea33f66 100644 --- a/src/Drupal/Commands/core/drush.services.yml +++ b/src/Drupal/Commands/core/drush.services.yml @@ -21,6 +21,26 @@ services: arguments: ['@entity_type.manager'] tags: - { name: drush.command } + field.create.commands: + class: \Drush\Drupal\Commands\core\FieldCreateCommands + arguments: + - '@plugin.manager.field.field_type' + - '@plugin.manager.field.widget' + - '@plugin.manager.entity_reference_selection' + - '@entity_type.manager' + - '@entity_type.bundle.info' + - '@module_handler' + - '@entity_field.manager' + calls: + - [ setContentTranslationManager, [ '@?content_translation.manager' ] ] + tags: + - { name: drush.command } + link.hooks: + class: \Drush\Drupal\Commands\core\LinkHooks + arguments: + - '@module_handler' + tags: + - { name: drush.command } image.commands: class: \Drush\Drupal\Commands\core\ImageCommands tags: diff --git a/tests/functional/FieldCreateTest.php b/tests/functional/FieldCreateTest.php new file mode 100644 index 0000000000..d2d1ed8fd2 --- /dev/null +++ b/tests/functional/FieldCreateTest.php @@ -0,0 +1,61 @@ +getSites()) { + $this->setUpDrupal(1, true); + // Create a content entity with bundles. + CreateEntityType::createContentEntity($this); + $this->drush('pm-enable', ['text,field_ui,unish_article']); + $this->drush('php:script', ['create_unish_article_bundles'], ['script-path' => Path::join(__DIR__, 'resources')]); + } + } + + public function testFieldCreate() + { + // Arguments. + $this->drush('field:create', [], [], null, null, self::EXIT_ERROR); + $this->assertStringContainsString('Not enough arguments (missing: "entityType")', $this->getErrorOutputRaw()); + $this->drush('field:create', ['foo'], [], null, null, self::EXIT_ERROR); + $this->assertStringContainsString('Entity type with id \'foo\' does not exist.', $this->getErrorOutputRaw()); + $this->drush('field:create', ['user'], [], null, null, self::EXIT_ERROR); + $this->assertStringNotContainsString('The bundle argument is required.', $this->getErrorOutputRaw()); + $this->drush('field:create', ['user', 'user'], [], null, null, self::EXIT_ERROR); + $this->assertStringNotContainsString('bundle', $this->getErrorOutputRaw()); + + // New field storage + $this->drush('field:create', ['unish_article', 'alpha'], ['field-label' => 'Test', 'field-name' => 'field_test2', 'field-type' => 'entity_reference', 'field-widget' => 'entity_reference_autocomplete', 'cardinality' => '-1'], null, null, self::EXIT_ERROR); + $this->assertStringContainsString('The target-type option is required.', $this->getErrorOutputRaw()); + /// @todo --target-bundle not yet validated. + // $this->drush('field:create', ['unish_article', 'alpha'], ['field-label' => 'Test', 'field-name' => 'field_test3', 'field-type' => 'entity_reference', 'field-widget' => 'entity_reference_autocomplete', 'cardinality' => '-1', 'target-type' => 'unish_article', 'target-bundle' => 'NO-EXIST']); + // $this->assertStringContainsString('TODO', $this->getErrorOutputRaw()); + $this->drush('field:create', ['unish_article', 'alpha'], ['field-label' => 'Test', 'field-name' => 'field_test3', 'field-type' => 'entity_reference', 'field-widget' => 'entity_reference_autocomplete', 'cardinality' => '-1', 'target-type' => 'unish_article', 'target-bundle' => 'beta']); + $this->assertStringContainsString("Successfully created field 'field_test3' on unish_article type with bundle 'alpha'", $this->getErrorOutputRaw()); + $this->assertStringContainsString("Further customisation can be done at the following url: +http://dev/admin/structure/unish_article_types/manage/alpha/fields/unish_article.alpha.field_test3", $this->getErrorOutputRaw()); + $php = "return Drupal::entityTypeManager()->getStorage('field_config')->load('unish_article.alpha.field_test3')->getSettings()"; + $this->drush('php:eval', [$php], ['format' => 'json']); + $settings = $this->getOutputFromJSON(); + $this->assertSame('unish_article', $settings['target_type']); + $this->assertEquals(['beta' => 'beta'], $settings['handler_settings']['target_bundles']); + $this->drush('field:create', ['unish_article', 'alpha'], ['field-name' => 'field_test3', 'field-label' => 'Body'], null, null, self::EXIT_ERROR); + $this->assertStringContainsString('--existing option', $this->getSimplifiedErrorOutput()); + + // Existing storage + $this->drush('field:create', ['unish_article', 'beta'], ['existing-field-name' => 'field_test3', 'field-label' => 'Body', 'field-widget' => 'text_textarea_with_summary']); + $this->assertStringContainsString('Success', $this->getErrorOutputRaw()); + $this->drush('field:create', ['unish_article', 'beta'], ['existing-field-name' => 'field_test3', 'field-label' => 'Body', 'field-widget' => 'text_textarea_with_summary'], null, null, self::EXIT_ERROR); + $this->assertStringContainsString('Field with name \'field_test3\' already exists on bundle \'beta\'', $this->getErrorOutputRaw()); + } +} diff --git a/tests/functional/UserTest.php b/tests/functional/UserTest.php index 82a716eb93..988c3b7176 100644 --- a/tests/functional/UserTest.php +++ b/tests/functional/UserTest.php @@ -115,38 +115,9 @@ public function testUserLogin() public function testUserCancel() { - $answers = [ - 'name' => 'Unish Article', - 'machine_name' => 'unish_article', - 'description' => 'A test module', - 'package' => 'unish', - 'dependencies' => 'drupal:text', - ]; - $this->drush('generate', ['module'], ['v' => null, 'answer' => $answers, 'destination' => Path::join(self::webroot(), 'modules/contrib')], null, null, self::EXIT_SUCCESS, null, ['SHELL_INTERACTIVE' => 1]); - // Create a content entity type and enable its module. - // Note that only the values below are used. The keys are for documentation. - $answers = [ - 'name' => 'unish_article', - 'entity_type_label' => 'UnishArticle', - 'entity_type_id' => 'unish_article', - 'entity_base_path' => 'admin/content/unish_article', - 'fieldable' => 'no', - 'revisionable' => 'no', - 'translatable' => 'no', - 'bundle' => 'No', - 'canonical page' => 'No', - 'entity template' => 'No', - 'CRUD permissions' => 'No', - 'label base field' => 'Yes', - 'status_base_field' => 'yes', - 'created_base_field' => 'yes', - 'changed_base_field' => 'yes', - 'author_base_field' => 'yes', - 'description_base_field' => 'no', - 'rest_configuration' => 'no', - ]; - $this->drush('generate', ['content-entity'], ['answer' => $answers, 'destination' => Path::join(self::webroot(), 'modules/contrib/unish_article')], null, null, self::EXIT_SUCCESS, null, ['SHELL_INTERACTIVE' => 1]); + CreateEntityType::createContentEntity($this); $this->drush('pm-enable', ['text,unish_article']); + $this->drush('php:script', ['create_unish_article_bundles'], ['script-path' => Path::join(__DIR__, 'resources')]); // Create one unish_article owned by our example user. $this->drush('php-script', ['create_unish_articles'], ['script-path' => Path::join(__DIR__, 'resources')]); // Verify that content entity exists. diff --git a/tests/functional/resources/create_unish_article_bundles.php b/tests/functional/resources/create_unish_article_bundles.php new file mode 100644 index 0000000000..3b686c6fd0 --- /dev/null +++ b/tests/functional/resources/create_unish_article_bundles.php @@ -0,0 +1,11 @@ +getStorage('unish_article_type'); +$storage->create([ + 'id' => 'alpha', + 'label' => 'Alpha', +])->save(); +$storage->create([ + 'id' => 'beta', + 'label' => 'Beta', +])->save(); diff --git a/tests/functional/resources/create_unish_articles.php b/tests/functional/resources/create_unish_articles.php index 374201c8ce..9f07424ab8 100644 --- a/tests/functional/resources/create_unish_articles.php +++ b/tests/functional/resources/create_unish_articles.php @@ -2,7 +2,8 @@ use Drupal\unish_article\Entity\UnishArticle; -$article = UnishArticle::create(); +/** @var \Drupal\Core\Entity\ContentEntityInterface $article */ +$article = UnishArticle::create(['bundle' => 'alpha']); $article->setOwnerId(2); // $article->setTitle('Unish wins.'); $article->save(); diff --git a/tests/unish/CommandUnishTestCase.php b/tests/unish/CommandUnishTestCase.php index da446c5a0d..7728367506 100644 --- a/tests/unish/CommandUnishTestCase.php +++ b/tests/unish/CommandUnishTestCase.php @@ -77,9 +77,9 @@ public function drushBackground($command, array $args = [], array $options = [], /** * Invoke drush in via execute(). * - * @param command + * @param $command * A defined drush command such as 'cron', 'status' or any of the available ones such as 'drush pm'. - * @param args + * @param $args * Command arguments. * @param $options * An associative array containing options. diff --git a/tests/unish/CreateEntityType.php b/tests/unish/CreateEntityType.php new file mode 100644 index 0000000000..701e5e229e --- /dev/null +++ b/tests/unish/CreateEntityType.php @@ -0,0 +1,46 @@ + 'Unish Article', + 'machine_name' => 'unish_article', + 'description' => 'A test module', + 'package' => 'unish', + 'dependencies' => 'drupal:text', + ]; + $testCase->drush('generate', ['module'], ['verbose' => null, 'answer' => $answers, 'destination' => Path::join($testCase->webroot(), 'modules/contrib')], null, null, $testCase::EXIT_SUCCESS, null, ['SHELL_INTERACTIVE' => 1]); + // Create a content entity type and enable its module. + // Note that only the values below are used. The keys are for documentation. + $answers = [ + 'name' => 'unish_article', + 'entity_type_label' => 'UnishArticle', + 'entity_type_id' => 'unish_article', + 'entity_base_path' => 'admin/content/unish_article', + 'fieldable' => 'yes', + 'revisionable' => 'no', + 'translatable' => 'no', + 'bundle' => 'Yes', + 'canonical page' => 'No', + 'entity template' => 'No', + 'CRUD permissions' => 'No', + 'label base field' => 'Yes', + 'status_base_field' => 'yes', + 'created_base_field' => 'yes', + 'changed_base_field' => 'yes', + 'author_base_field' => 'yes', + 'description_base_field' => 'no', + 'rest_configuration' => 'no', + ]; + $testCase->drush('generate', ['content-entity'], ['answer' => $answers, 'destination' => Path::join($testCase::webroot(), 'modules/contrib/unish_article')], null, null, $testCase::EXIT_SUCCESS, null, ['SHELL_INTERACTIVE' => 1]); + } +}