diff --git a/core/modules/media/js/media_file_widget.js b/core/modules/media/js/media_file_widget.js new file mode 100644 index 0000000000..556a46be3a --- /dev/null +++ b/core/modules/media/js/media_file_widget.js @@ -0,0 +1,19 @@ +/** + * @file + * Javascript for the media file widget. + */ + +(function (Drupal) { + + "use strict"; + + Drupal.AjaxCommands.prototype.open_url = function (ajax, response, status) { + Drupal.ajax({ + dialog: { width: '70%', height: 500 }, + dialogType: 'modal', + selector: 'body', + url: response.url + }).execute(); + }; + +})(Drupal); diff --git a/core/modules/media/media.field.inc b/core/modules/media/media.field.inc new file mode 100644 index 0000000000..f23b1ac00a --- /dev/null +++ b/core/modules/media/media.field.inc @@ -0,0 +1,108 @@ + t('Display'), + 'class' => ['checkbox'], + ]; + } + $headers[] = t('Weight'); + $headers[] = t('Operations'); + + // Get our list of widgets in order (needed when the form comes back after + // preview or failed validation). + $widgets = []; + foreach (Element::children($element['selection']) as $key) { + $widgets[] = &$element['selection'][$key]; + } + usort($widgets, '_field_multiple_value_form_sort_helper'); + + $rows = []; + foreach ($widgets as $key => &$widget) { + $file_field = &$widget['file_field']; + + // Delay rendering of the buttons, so that they can be rendered later in the + // "operations" column. + $operations_elements = []; + foreach (Element::children($file_field) as $sub_key) { + if (isset($file_field[$sub_key]['#type']) && $file_field[$sub_key]['#type'] == 'submit') { + hide($file_field[$sub_key]); + $operations_elements[] = &$file_field[$sub_key]; + } + } + + // Delay rendering the weight selector, so that + // it can be rendered later in its own column. + hide($widget['_weight']); + + // Render everything else together in a column, without the normal wrappers. + $file_field['#theme_wrappers'] = []; + $information = \Drupal::service('renderer')->render($file_field); + + $widget['_weight']['#attributes']['class'] = [$weight_class]; + $weight = render($widget['_weight']); + + // Arrange the row with all of the rendered columns. + $row = []; + $row[] = $information; + $row[] = $weight; + + // Show the buttons that had previously been marked as hidden in this + // preprocess function. We use show() to undo the earlier hide(). + foreach (Element::children($operations_elements) as $key) { + show($operations_elements[$key]); + } + $row[] = [ + 'data' => $operations_elements, + ]; + $rows[] = [ + 'data' => $row, + 'class' => isset($file_field['#attributes']['class']) ? array_merge($file_field['#attributes']['class'], ['draggable']) : ['draggable'], + ]; + } + + $variables['table'] = [ + '#type' => 'table', + '#header' => $headers, + '#rows' => $rows, + '#attributes' => [ + 'id' => $table_id, + ], + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => $weight_class, + ], + ], + '#access' => !empty($rows), + ]; + + $variables['element'] = $element; +} diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml index eecaf8ed1f..e89157b5d6 100644 --- a/core/modules/media/media.libraries.yml +++ b/core/modules/media/media.libraries.yml @@ -11,3 +11,10 @@ media_type_form: js/media_type_form.js: {} dependencies: - core/drupal.form + +media_file_widget: + version: VERSION + js: + 'js/media_file_widget.js': {} + dependencies: + - core/drupal.dialog.ajax diff --git a/core/modules/media/media.module b/core/modules/media/media.module index eeb2ac168a..71cb982eab 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -44,6 +44,10 @@ function media_theme() { 'media' => [ 'render element' => 'elements', ], + 'media_file_widget_multiple' => [ + 'render element' => 'element', + 'file' => 'media.field.inc', + ], ]; } diff --git a/core/modules/media/src/Ajax/OpenUrlCommand.php b/core/modules/media/src/Ajax/OpenUrlCommand.php new file mode 100644 index 0000000000..4da382a5e7 --- /dev/null +++ b/core/modules/media/src/Ajax/OpenUrlCommand.php @@ -0,0 +1,40 @@ +url = $url instanceof Url ? $url->toString() : $url; + } + + /** + * {@inheritdoc} + */ + public function render() { + return [ + 'command' => 'open_url', + 'url' => $this->url, + ]; + } + +} diff --git a/core/modules/media/src/Controller/MediaController.php b/core/modules/media/src/Controller/MediaController.php new file mode 100644 index 0000000000..b798bc80f8 --- /dev/null +++ b/core/modules/media/src/Controller/MediaController.php @@ -0,0 +1,169 @@ +request = $request; + $build = parent::addPage($entity_type_id); + + // Filter bundles for the file widget modal. + if ($this->request->get('modal') == 'media_file') { + $types = &$build['#bundles']; + + $field_id = $this->request->get('field_id'); + if ($field_id) { + $handler_settings = $this->entityTypeManager + ->getStorage('field_config') + ->load($field_id) + ->getSetting('handler_settings'); + + // Only allow types that can be referenced by the field. + $types = array_intersect_key($types, array_flip($handler_settings['target_bundles'])); + } + + $fids = $this->request->get('fids'); + if ($fids) { + /** @var \Drupal\file\FileInterface $file */ + $file = $this->entityTypeManager + ->getStorage('file') + ->load($fids[0]); + + // Only allow types whose source field supports the file. + $types = array_intersect_key($types, $this->getSupportedTypesForFile($file, array_keys($types))); + } + + // Redirect to media add form if we only found 1 type. + if (count($types) === 1) { + $route_parameters = [ + 'media_type' => key($types), + ]; + return $this->redirect('entity.media.add_form', $route_parameters, [], 302); + } + else { + $types = $this->propagate($types); + } + } + + return $build; + } + + /** + * {@inheritdoc} + */ + public function addTitle($entity_type_id, Request $request = NULL) { + $this->request = $request; + $title = parent::addTitle($entity_type_id); + + // Change title for the file widget modal. + if ($this->request->get('modal') == 'media_file') { + $fids = $request->get('fids'); + /** @var \Drupal\file\FileInterface $file */ + $file = $this->entityTypeManager + ->getStorage('file') + ->load($fids[0]); + $title = $this->t('Add @filename', ['@filename' => $file->getFilename()]); + } + + return $title; + } + + /** + * Add query parameters to types and make them open in a modal. + * + * @param array $types + * The types to change. + * + * @return array + * The changed types. + */ + protected function propagate(array $types) { + foreach ($types as $id => $element) { + $url_options = [ + 'query' => $this->request->query->all(), + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + ], + ]; + $route_parameters = ['media_type' => $id]; + $url = Url::fromRoute('entity.media.add_form', $route_parameters, $url_options); + $types[$id]['add_link']->setUrl($url); + } + return $types; + } + + /** + * {@inheritdoc} + */ + protected function redirect($route_name, array $route_parameters = [], array $options = [], $status = 302) { + $options = array_merge_recursive($options, [ + 'query' => $this->request->query->all(), + ]); + $redirect = parent::redirect($route_name, $route_parameters, $options, $status); + + // Change redirect response for the file widget modal. + if ($this->request->get('modal') == 'media_file') { + return (new AjaxResponse) + ->addCommand(new OpenUrlCommand($redirect->getTargetUrl())); + } + else { + return $redirect; + } + } + + /** + * Filter a list of media types by the extension of a file. + * + * @param \Drupal\file\FileInterface $file + * The uploaded file. + * @param array $types + * The media types to check for support. + * + * @return array + * A list of media types that support the file extension. + */ + protected function getSupportedTypesForFile(FileInterface $file, array $types = NULL) { + /** @var \Drupal\media\MediaTypeInterface[] $types */ + $types = $this->entityTypeManager + ->getStorage('media_type') + ->loadMultiple($types); + + $extension = pathinfo($file->getFilename(), PATHINFO_EXTENSION); + + return array_filter($types, function (MediaTypeInterface $type) use ($extension) { + $handler = $type->getSource(); + if ($handler instanceof MediaSourceInterface) { + $field = $handler->getSourceFieldDefinition($type); + if (in_array($field->getType(), ['file', 'image'])) { + return in_array($extension, explode(' ', $field->getSetting('file_extensions'))); + } + } + return FALSE; + }); + } + +} diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php index 858404112e..50717af56b 100644 --- a/core/modules/media/src/Entity/Media.php +++ b/core/modules/media/src/Entity/Media.php @@ -42,7 +42,7 @@ * "translation" = "Drupal\content_translation\ContentTranslationHandler", * "views_data" = "Drupal\media\MediaViewsData", * "route_provider" = { - * "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider", + * "html" = "Drupal\media\Routing\MediaAdminHtmlRouteProvider", * } * }, * base_table = "media", diff --git a/core/modules/media/src/MediaForm.php b/core/modules/media/src/MediaForm.php index 1e91e9f627..4d83b172bd 100644 --- a/core/modules/media/src/MediaForm.php +++ b/core/modules/media/src/MediaForm.php @@ -2,8 +2,13 @@ namespace Drupal\media; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\CloseModalDialogCommand; +use Drupal\Core\Ajax\InvokeCommand; use Drupal\Core\Entity\ContentEntityForm; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; +use Drupal\media\Ajax\OpenUrlCommand; /** * Form controller for the media edit forms. @@ -60,6 +65,15 @@ public function form(array $form, FormStateInterface $form_state) { protected function actions(array $form, FormStateInterface $form_state) { $element = parent::actions($form, $form_state); $media = $this->entity; + $request = $this->getRequest(); + + // Add extra submit handler for ajax requests. + if ($request->query->get('modal') == 'media_file') { + $element['submit']['#submit'][] = '::fileWidgetAfterSave'; + $element['submit']['#ajax'] = [ + 'callback' => '::fileWidgetAjaxSubmit', + ]; + } // Add a "Publish" button. $element['publish'] = $element['submit']; @@ -105,6 +119,12 @@ protected function actions(array $form, FormStateInterface $form_state) { $element['delete']['#access'] = $media->access('delete'); $element['delete']['#weight'] = 100; + // Ajax doesn't work for dropbuttons in modal. + if ($request->query->get('modal') == 'media_file') { + unset($element['publish']['#dropbutton']); + unset($element['unpublish']['#dropbutton']); + } + return $element; } @@ -154,4 +174,102 @@ public function save(array $form, FormStateInterface $form_state) { return $saved; } + /** + * {@inheritdoc} + */ + protected function prepareEntity() { + parent::prepareEntity(); + + $fids = $this->getRequest()->query->get('fids'); + if ($fids) { + // @todo: Fix file upload location. + $file = $this->entityTypeManager + ->getStorage('file') + ->load($fids[0]); + + $handler = $this->entity->getSource(); + if ($handler instanceof MediaSourceInterface) { + $this->entity->set($handler->getSourceFieldDefinition($this->entity->bundle->entity)->getName(), $file); + } + } + } + + /** + * Form submission handler for file widgets after 'save' action. + * + * Normally the user is redirected to the created entity after saving. + * In the modal this is not needed. We also want to store the entity id in the + * form state so we can pass it back to the parent form in the ajax submit. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function fileWidgetAfterSave(array &$form, FormStateInterface $form_state) { + $form_state->disableRedirect(); + $form_state->setValue('entity_id', $this->entity->id()); + } + + /** + * Ajax form submission handler for file widgets. + * + * After saving an entity we need to check if there are more files to add + * entities for. In that case we go back to the media add page to select + * a type for the next file. + * After saving entities for all files, the new entity ID needs to be passed + * back to the parent form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The ajax response for the form. + */ + public function fileWidgetAjaxSubmit(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + + $field_id = $this->getRequest()->query->get('field_id'); + $fids = array_slice($this->getRequest()->query->get('fids'), 1); + $entity_id = $form_state->getValue('entity_id'); + + // Check if we have a saved entity. + if ($entity_id) { + if ($field_id) { + $field_name = $this->entityTypeManager + ->getStorage('field_config') + ->load($field_id) + ->getName(); + + // Pass id back to field widget and trigger form update. + $input_selector = '[data-media-file-input-id="' . $field_name . '"]'; + $submit_selector = '[data-media-file-submit-id="' . $field_name . '"]'; + $response->addCommand(new InvokeCommand($input_selector, 'val', [$entity_id])); + + // todo: Only rebuild the form after all media entities are saved. + $response->addCommand(new InvokeCommand($submit_selector, 'trigger', ['mousedown'])); + } + } + + // If we still have file IDs, return to media add list for next file, + // or close the modal if all files are added. + if ($fids) { + $url = Url::fromRoute('entity.media.add_page', [], [ + 'query' => [ + 'modal' => 'media_file', + 'field_id' => $field_id, + 'fids' => $fids, + ], + ]); + $response->addCommand(new OpenUrlCommand($url)); + } + else { + $response->addCommand(new CloseModalDialogCommand()); + } + + return $response; + } + } diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php new file mode 100644 index 0000000000..377e7f1bb1 --- /dev/null +++ b/core/modules/media/src/Plugin/Field/FieldWidget/MediaFileWidget.php @@ -0,0 +1,766 @@ +elementInfo = $element_info; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['third_party_settings'], + $container->get('element_info'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'progress_indicator' => 'throbber', + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $element['progress_indicator'] = [ + '#type' => 'radios', + '#title' => t('Progress indicator'), + '#options' => [ + 'throbber' => t('Throbber'), + 'bar' => t('Bar with progress meter'), + ], + '#default_value' => $this->getSetting('progress_indicator'), + '#description' => t('The throbber display does not show the status of uploads but takes up less space. The progress bar is helpful for monitoring progress on large uploads.'), + '#weight' => 16, + '#access' => file_progress_implementation(), + ]; + return $element; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = []; + $summary[] = t('Progress indicator: @progress_indicator', ['@progress_indicator' => $this->getSetting('progress_indicator')]); + return $summary; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + if (parent::isApplicable($field_definition) && $field_definition->getSetting('target_type') == 'media') { + /** @var \Drupal\media\Entity\MediaType[] $bundles */ + $allowed_types = MediaType::loadMultiple($field_definition->getSetting('handler_settings')['target_bundles']); + + $media_handlers = []; + $definitions = \Drupal::service('plugin.manager.field.widget')->getDefinitions(); + foreach ($definitions as $definition) { + if ($definition['class'] == static::class) { + $media_handlers = $definition['media_handlers']; + } + } + + foreach ($allowed_types as $type) { + /** @var \Drupal\media\Entity\MediaType $type */ + if (in_array($type->getSource()->getPluginId(), $media_handlers)) { + return TRUE; + } + } + } + return FALSE; + } + + /** + * Override form method. + * + * Special handling for updating items from the field state and for + * providing a ajax wrapper. + * + * @param \Drupal\Core\Field\FieldItemListInterface $items + * The fields items for the widget. + * @param array $form + * The widget form elements. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form_state of the widget form. + * @param int $get_delta + * Used to get only a specific delta value of a multiple value field. + * + * @return array + * The basic form structure. + */ + public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL) { + $field_name = $this->fieldDefinition->getName(); + $parents = $form['#parents']; + $wrapper_id = $field_name . '-ajax-wrapper'; + + // Load the items for form rebuilds from the field state as they might + // not be in $form_state['values'] because of validation limitations. + $field_state = static::getWidgetState($parents, $field_name, $form_state); + if ($field_state && isset($field_state['items'])) { + $items->setValue($field_state['items']); + } + $build = parent::form($items, $form, $form_state); + + // Add a hidden textfield to the form, which is used to pass values + // from the modal to the widget. This is similar to how the + // EntityReferenceAutocompleteWidget stores values. + $build[$field_name . '_media_file_selection'] = [ + '#type' => 'textfield', + '#name' => $field_name . '-media-file-selection', + '#attributes' => [ + 'data-media-file-input-id' => $field_name, + 'class' => ['visually-hidden'], + ], + ]; + $build[$field_name . '_media_file_update_widget'] = [ + '#type' => 'submit', + '#name' => $field_name . '-media-file-update-button', + '#value' => $this->t('Add selection'), + '#submit' => [[static::class, 'addItems']], + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + '#attributes' => [ + 'data-media-file-submit-id' => $field_name, + 'class' => ['visually-hidden'], + ], + '#limit_validation_errors' => [array_merge($parents, [$field_name])], + ]; + + // Wrap the widget in a div so that it can be re-loaded via AJAX. + $build['#prefix'] = '
'; + $build['#suffix'] = '
'; + return $build; + } + + /** + * Create a multivalue reference field. + * + * Special handling for draggable multiple widgets and upload field. + * + * @param \Drupal\Core\Field\FieldItemListInterface $items + * The fields items for the widget. + * @param array $form + * The widget form elements. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form_state of the widget form. + * + * @return array + * The widget element. + */ + protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + + // Determine the number of widgets to display. + $cardinality = $this->fieldDefinition->getFieldStorageDefinition()->getCardinality(); + switch ($cardinality) { + case FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED: + $max = count($items); + $is_multiple = TRUE; + break; + + default: + $max = $cardinality - 1; + $is_multiple = ($cardinality > 1); + break; + } + + $title = $this->fieldDefinition->getLabel(); + $description = $this->getFilteredDescription(); + + $elements = [ + 'selection' => [ + '#type' => 'container', + ], + ]; + + $delta = 0; + // Add an element for every existing item. + foreach ($items as $item) { + $element = [ + '#title' => $title, + '#description' => $description, + ]; + $element = $this->formSingleElement($items, $delta, $element, $form, $form_state); + + if ($element) { + // Input field for the delta (drag-n-drop reordering). + if ($is_multiple) { + // We name the element '_weight' to avoid clashing with elements + // defined by widget. + $element['_weight'] = [ + '#type' => 'weight', + '#title' => t('Weight for row @number', ['@number' => $delta + 1]), + '#title_display' => 'invisible', + // Note: this 'delta' is the FAPI #type 'weight' element's property. + '#delta' => $max, + '#default_value' => $item->_weight ?: $delta, + '#weight' => 100, + ]; + } + + $elements['selection'][$delta] = $element; + $delta++; + } + } + + $empty_single_allowed = ($cardinality == 1 && $delta == 0); + $empty_multiple_allowed = ($cardinality == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED || $delta < $cardinality) && !$form_state->isProgrammed(); + + // Add a managed file field for new uploads except when this is a programmed + // multiple form. + if ($empty_single_allowed || $empty_multiple_allowed) { + + // Fetch the upload validators from the source field. + $upload_validators = []; + $allowed_types = MediaType::loadMultiple($this->fieldDefinition->getSetting('handler_settings')['target_bundles']); + foreach ($allowed_types as $type) { + /** @var \Drupal\media\Entity\MediaType $type */ + $source_field = $type->getSource()->getSourceFieldDefinition($type); + $source_data_definition = FieldItemDataDefinition::create($source_field); + $file_item = new FileItem($source_data_definition); + $upload_validators = array_merge($upload_validators, $file_item->getUploadValidators()); + } + + // Essentially we use the managed_file type, extended with some + // enhancements. + $element_info = $this->elementInfo->getInfo('managed_file'); + $file_upload_help = [ + '#theme' => 'file_upload_help', + '#upload_validators' => $upload_validators, + '#cardinality' => $cardinality, + ]; + $elements['upload'][$delta][$field_name . '_media_file_upload'] = [ + '#title' => $title, + '#description' => $description, + '#type' => 'managed_file', + '#required' => $delta == 0 && $this->fieldDefinition->isRequired(), + '#upload_validators' => $upload_validators, + '#description' => \Drupal::service('renderer')->renderPlain($file_upload_help), + '#value_callback' => [get_class($this), 'uploadFieldValueCallback'], + '#process' => array_merge($element_info['#process'], [[get_class($this), 'uploadFieldProcess']]), + '#progress_indicator' => $this->getSetting('progress_indicator'), + '#extended' => TRUE, + '#element_validate' => $element_info['#element_validate'], + // Add properties needed by value() and process() methods. + '#field_name' => $this->fieldDefinition->getName(), + '#bundle' => $this->fieldDefinition->getTargetBundle(), + '#entity_type' => $this->fieldDefinition->getTargetEntityTypeId(), + '#cardinality' => $cardinality, + '#attached' => array_merge_recursive( + $element_info['#attached'], + ['library' => ['media/media_file_widget']] + ), + '#multiple' => $cardinality != 1 ? TRUE : FALSE, + ]; + if ($cardinality != 1 && $cardinality != -1) { + $elements['upload'][$delta][$field_name . '_media_file_upload']['#element_validate'] = [ + [static::class, 'validateMultipleCount'], + ]; + } + + } + + if ($is_multiple) { + // The group of elements all-together need some extra functionality after + // building up the full list (like draggable table rows). + $elements['#file_upload_delta'] = $delta; + $elements['#type'] = 'details'; + $elements['#open'] = TRUE; + $elements['#theme'] = 'media_file_widget_multiple'; + $elements['#theme_wrappers'] = ['details']; + $elements['#process'] = [[get_class($this), 'processMultiple']]; + $elements['#title'] = $title; + $elements['#description'] = $description; + $elements['#field_name'] = $field_name; + unset($elements['upload'][$delta][$field_name . '_media_file_upload']['#title']); + unset($elements['upload'][$delta][$field_name . '_media_file_upload']['#description']); + } + + return $elements; + } + + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + $wrapper_id = $field_name . '-ajax-wrapper'; + + /** @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items */ + $referenced_entities = $items->referencedEntities(); + if (isset($referenced_entities[$delta])) { + /** @var \Drupal\media\Entity\Media $media */ + $media = $referenced_entities[$delta]; + /** @var \Drupal\field\FieldConfigInterface $source_field */ + $source_field = $media->getSource()->getSourceFieldDefinition($media->bundle->entity); + /** @var \Drupal\file\Entity\File $file */ + $file = $media->get($source_field->getName())->entity; + + // We use a hidden field weith the media id and show a managed file + // to show the source field value for the media item. + $element += [ + '#field_name' => $field_name, + // Add media entity values for storage. + 'target_id' => [ + '#type' => 'hidden', + '#value' => $media->id(), + ], + '#weight' => $delta, + // Add file field for display. + 'file_field' => [ + '#type' => 'managed_file', + '#value' => [ + 'fids' => [$file->id()], + ], + '#process' => [], + 'remove_button' => [ + '#name' => implode('_', [ + $field_name, + $element['#delta'], + 'remove_button', + ]), + '#type' => 'submit', + '#value' => t('Remove'), + '#remove_delta' => $element['#delta'], + '#attributes' => ['class' => ['remove-button']], + '#validate' => [], + '#submit' => [[static::class, 'removeItem']], + '#limit_validation_errors' => [array_merge($form['#parents'], [$field_name])], + '#ajax' => [ + 'callback' => [static::class, 'updateWidget'], + 'wrapper' => $wrapper_id, + 'effect' => 'fade', + ], + '#weight' => 1, + '#access' => TRUE, + ], + 'file_' . $delta => [ + 'filename' => [ + '#theme' => 'file_link', + '#file' => $file, + '#weight' => -10, + ], + ], + ], + ]; + } + return $element; + } + + /** + * {@inheritdoc} + */ + public function errorElement(array $element, ConstraintViolationInterface $error, array $form, FormStateInterface $form_state) { + return isset($element['target_id']) ? $element['target_id'] : FALSE; + } + + /** + * {@inheritdoc} + */ + public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) { + $field_name = $this->fieldDefinition->getName(); + + // Extract the values from $form_state->getValues(). + $path = array_merge($form['#parents'], [$field_name]); + $key_exists = NULL; + $values = NestedArray::getValue($form_state->getValues(), $path, $key_exists); + + // Get the actual values from the selection container. + if (isset($values['selection'])) { + $values = $values['selection']; + } + + if ($key_exists) { + // Account for drag-and-drop reordering if needed. + if (!$this->handlesMultipleValues()) { + // Remove the 'value' of the 'add more' button. + unset($values['add_more']); + + // The original delta, before drag-and-drop reordering, is needed to + // route errors to the correct form element. + foreach ($values as $delta => &$value) { + $value['_original_delta'] = $delta; + } + + usort($values, function ($a, $b) { + return SortArray::sortByKeyInt($a, $b, '_weight'); + }); + } + + // Let the widget massage the submitted values. + $values = $this->massageFormValues($values, $form, $form_state); + + // Assign the values and remove the empty ones. + $items->setValue($values); + $items->filterEmptyItems(); + + // Put delta mapping in $form_state, so that flagErrors() can use it. + $field_state = static::getWidgetState($form['#parents'], $field_name, $form_state); + foreach ($items as $delta => $item) { + $field_state['original_deltas'][$delta] = isset($item->_original_delta) ? $item->_original_delta : $delta; + unset($item->_original_delta, $item->_weight); + } + static::setWidgetState($form['#parents'], $field_name, $form_state, $field_state); + } + } + + /** + * Processes a group of media elements. + * + * This method on is assigned as a #process callback in formMultipleElements() + * method. + * + * @param array $element + * The widget element array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $form + * The form array. + * + * @return array + * The changed widget element. + */ + public static function processMultiple(array $element, FormStateInterface $form_state, array $form) { + $element_children = Element::children($element, TRUE); + foreach ($element_children as $delta => $key) { + // The title needs to be assigned to the upload field so that validation + // errors include the correct widget label. + $element[$key]['#title'] = $element['#title']; + } + return $element; + } + + /** + * Form element validation callback for upload element on file widget. + * + * Checks if the user has uploaded more files than allowed. + * This validator is used only when cardinality not set to 1 or unlimited. + * + * @param array $element + * The widget element array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $form + * The form array. + */ + public static function validateMultipleCount(array $element, FormStateInterface $form_state, array $form) { + $values = NestedArray::getValue($form_state->getValues(), $element['#parents']); + + $array_parents = $element['#array_parents']; + array_pop($array_parents); + $previously_uploaded_count = count(Element::children(NestedArray::getValue($form, $array_parents))) - 1; + + $field_storage_definitions = \Drupal::entityManager()->getFieldStorageDefinitions($element['#entity_type']); + $field_storage = $field_storage_definitions[$element['#field_name']]; + $newly_uploaded_count = count($values['fids']); + $total_uploaded_count = $newly_uploaded_count + $previously_uploaded_count; + if ($total_uploaded_count > $field_storage->getCardinality()) { + $keep = $newly_uploaded_count - $total_uploaded_count + $field_storage->getCardinality(); + $removed_files = array_slice($values['fids'], $keep); + $removed_names = []; + foreach ($removed_files as $fid) { + $file = File::load($fid); + $removed_names[] = $file->getFilename(); + } + $args = [ + '%field' => $field_storage->getName(), + '@max' => $field_storage->getCardinality(), + '@count' => $total_uploaded_count, + '%list' => implode(', ', $removed_names), + ]; + $message = t('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args); + drupal_set_message($message, 'warning'); + $values['fids'] = array_slice($values['fids'], 0, $keep); + NestedArray::setValue($form_state->getValues(), $element['#parents'], $values); + } + } + + /** + * Process callback for the upload field. + * + * Change the ajax functionality for the upload button. + * + * @param array $element + * The file field element array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param array $form + * The form array. + * + * @return array + * The changed file field element. + */ + public static function uploadFieldProcess(array $element, FormStateInterface $form_state, array $form) { + // Override the ajax callback on the button. + $element['upload_button']['#ajax']['callback'] = [static::class, 'uploadFieldAjaxCallback']; + return $element; + } + + /** + * Value callback for the upload field. + * + * Provide defaults for the field when uploading media. + * + * @param array $element + * An associative array containing the properties of the element. + * @param mixed $input + * The incoming input to populate the form element. If this is FALSE, + * the element's default value should be returned. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * The value to assign to the element. + */ + public static function uploadFieldValueCallback(array $element, $input, FormStateInterface $form_state) { + // We depend on the managed file element to handle uploads. + $return = ManagedFile::valueCallback($element, $input, $form_state); + // Ensure that all the required properties are returned even if empty. + $return += [ + 'fids' => [], + ]; + return $return; + } + + /** + * Ajax callback for the upload field. + * + * This ajax callback takes care of the following things: + * - Add commands from ManagedFile::uploadAjaxCallback() to handle + * file uploads. + * - Pass the file IDs to a modal. + * + * @param array $form + * The build form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * The ajax response for the widget. + */ + public static function uploadFieldAjaxCallback(array &$form, FormStateInterface &$form_state, Request $request) { + $response = new AjaxResponse(); + + $form_parents = explode('/', $request->query->get('element_parents')); + $element = NestedArray::getValue($form, $form_parents); + + if (!empty($element['#value']['fids'])) { + // Open modal to add media. + $url = Url::fromRoute('entity.media.add_page', [], [ + 'query' => [ + 'modal' => 'media_file', + 'field_id' => sprintf( + '%s.%s.%s', + $element['#entity_type'], + $element['#bundle'], + $element['#field_name'] + ), + 'fids' => $element['#value']['fids'], + ], + ]); + $response->addCommand(new OpenUrlCommand($url)); + + } + else { + // @todo: Rebuild widget to show errors. + } + + return $response; + } + + /** + * Submission handler for the hidden "Add selection" button. + * + * Adds media to the field state of the widget. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function addItems(array $form, FormStateInterface $form_state) { + $button = $form_state->getTriggeringElement(); + + // Go one level up in the form, to the widgets container. + $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1))['widget']; + $field_name = $element['#field_name']; + $parents = $element['#field_parents']; + + // Get the new media ids passed to our hidden button. + $user_input = $form_state->getUserInput(); + $input_key = $field_name . '-media-file-selection'; + if (isset($user_input[$input_key])) { + $values = $form_state->getValue($field_name); + $field_state = static::getWidgetState($parents, $field_name, $form_state); + + // Add new media entity to field. + $ids = explode(',', $user_input[$input_key]); + /** @var \Drupal\media\MediaInterface[] $media */ + $media = \Drupal::entityTypeManager()->getStorage('media')->loadMultiple($ids); + foreach ($media as $media_item) { + if ($media && $media_item->access('view')) { + $values['selection'][] = [ + 'target_id' => $media_item->id(), + ]; + } + } + + $field_state['items'] = $values['selection']; + $field_state['items_count'] = count($field_state['items']); + + static::setWidgetState($parents, $field_name, $form_state, $field_state); + + $form_state->setRebuild(); + } + } + + /** + * Submission handler for remove buttons on added media. + * + * Remove media from the field state of the widget. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public static function removeItem(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + + $parents = $triggering_element['#array_parents']; + $parents = array_slice($parents, 0, -4); + $element = NestedArray::getValue($form, $parents); + $field_name = $element['#field_name']; + $parents = $element['#field_parents']; + $delta = $triggering_element['#remove_delta']; + + // Find and remove correct entity. + $values = $form_state->getValue($field_name); + $field_state = static::getWidgetState($parents, $field_name, $form_state); + if (isset($values['selection'])) { + if (isset($values['selection'][$delta])) { + array_splice($values['selection'], $delta, 1); + $field_state['items'] = $values['selection']; + $field_state['items_count'] = count($field_state['items']); + } + } + $form_state->setValue($field_name, $values); + static::setWidgetState($parents, $field_name, $form_state, $field_state); + + // Rebuild form. + $form_state->setRebuild(); + } + + /** + * AJAX callback to update the widget when the selection changes. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * An array representing the updated widget. + */ + public static function updateWidget(array $form, FormStateInterface $form_state) { + $triggering_element = $form_state->getTriggeringElement(); + $length = isset($triggering_element['#remove_delta']) ? -5 : -1; + $parents = $triggering_element['#array_parents']; + $parents = array_slice($parents, 0, $length); + $element = NestedArray::getValue($form, $parents); + return $element; + } + +} diff --git a/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php b/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php new file mode 100644 index 0000000000..4742ca0a53 --- /dev/null +++ b/core/modules/media/src/Routing/MediaAdminHtmlRouteProvider.php @@ -0,0 +1,26 @@ +setDefault('_controller', MediaController::class . '::addPage'); + $route->setDefault('_title_callback', MediaController::class . '::addTitle'); + } + return $route; + } + +} diff --git a/core/modules/media/templates/media-file-widget-multiple.html.twig b/core/modules/media/templates/media-file-widget-multiple.html.twig new file mode 100644 index 0000000000..358b1cca2e --- /dev/null +++ b/core/modules/media/templates/media-file-widget-multiple.html.twig @@ -0,0 +1,16 @@ +{# +/** + * @file + * Default theme implementation to display a multiple media file widget. + * + * Available variables: + * - table: Table of previously uploaded files. + * - element: The form element for uploading another file. + * + * @see template_preprocess_file_widget_multiple() + * + * @ingroup themeable + */ +#} +{{ table }} +{{ element }} diff --git a/core/modules/system/tests/modules/entity_test/src/Controller/EntityTestEntityController.php b/core/modules/system/tests/modules/entity_test/src/Controller/EntityTestEntityController.php new file mode 100644 index 0000000000..823cea9582 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Controller/EntityTestEntityController.php @@ -0,0 +1,30 @@ +getUrl()->setOption('attributes', [ + 'class' => ['bundle-link'], + ]); + } + return $response; + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php index e6cb5428b9..563aaf1418 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php @@ -21,7 +21,7 @@ * "delete" = "\Drupal\Core\Entity\EntityDeleteForm" * }, * "route_provider" = { - * "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider", + * "html" = "Drupal\entity_test\Routing\HtmlRouteProvider", * }, * }, * base_table = "entity_test_with_bundle", diff --git a/core/modules/system/tests/modules/entity_test/src/Routing/HtmlRouteProvider.php b/core/modules/system/tests/modules/entity_test/src/Routing/HtmlRouteProvider.php new file mode 100644 index 0000000000..475cd34d28 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Routing/HtmlRouteProvider.php @@ -0,0 +1,23 @@ +setDefault('_controller', EntityTestEntityController::class . '::addPage') + ->setOption('_admin_route', TRUE); + } + +} diff --git a/core/modules/system/tests/src/Functional/SevenBundleAddPageLinkAttributesTest.php b/core/modules/system/tests/src/Functional/SevenBundleAddPageLinkAttributesTest.php new file mode 100644 index 0000000000..3a200a929c --- /dev/null +++ b/core/modules/system/tests/src/Functional/SevenBundleAddPageLinkAttributesTest.php @@ -0,0 +1,46 @@ +drupalCreateUser(['administer entity_test_with_bundle content']); + $this->drupalLogin($account); + + $this->config('system.theme')->set('default', 'seven')->save(); + + for ($i = 0; $i < 2; $i++) { + EntityTestBundle::create([ + 'id' => $this->randomMachineName(), + 'label' => $this->randomString(), + 'description' => $this->randomString(), + ])->save(); + } + + $this->drupalGet('/entity_test_with_bundle/add'); + $this->assertSession()->elementExists('css', 'a.bundle-link'); + } + +} diff --git a/core/themes/seven/templates/entity-add-list.html.twig b/core/themes/seven/templates/entity-add-list.html.twig index 3ff2e717df..f00ecd1fc2 100644 --- a/core/themes/seven/templates/entity-add-list.html.twig +++ b/core/themes/seven/templates/entity-add-list.html.twig @@ -17,7 +17,7 @@ {% if bundles is not empty %} {% elseif add_bundle_message is not empty %}