Commit dabf0613 authored by Steven Jones's avatar Steven Jones
Browse files

Issue #2886357 by xperd, steven jones, joelpittet, upchuk, nikolay borisov,...

Issue #2886357 by xperd, steven jones, joelpittet, upchuk, nikolay borisov, smulvih2, chizh273, qusai taha, reszli, sj.suraj, matio89, sissonen: Allow changing default "<response>" and "<item>" tags in XML export
parent fcafcfea
Loading
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -123,3 +123,13 @@ views.style.data_export:
            company:
              type: string
              label: 'Company'
    xml_settings:
      type: mapping
      label: 'XML settings'
      mapping:
        root_node_name:
          type: string
          label: 'Root node name'
        item_node_name:
          type: string
          label: 'Item node name'
+8 −4
Original line number Diff line number Diff line
@@ -815,15 +815,19 @@ class DataExport extends RestExport {
    // Workaround for XML.
    $output_format = reset($view->getStyle()->options['formats']);
    if ($output_format == 'xml') {
      $format_options = $view->getStyle()->options['xml_settings'];
      $root_node_name = $format_options['root_node_name'];
      $maximum = $export_limit ? $export_limit : $total_rows;
      // Remove xml declaration and response opening tag.

      // Remove xml declaration, root node opening tag.
      if ($context['sandbox']['progress'] != 0) {
        $string = str_replace('<?xml version="1.0"?>', '', $string);
        $string = str_replace('<response>', '', $string);
        $string = str_replace("<{$root_node_name}>", '', $string);
      }
      // Remove response closing tag.

      // Remove root node closing tag.
      if ($context['sandbox']['progress'] + $items_this_batch < $maximum) {
        $string = str_replace('</response>', '', $string);
        $string = str_replace("</{$root_node_name}>", '', $string);
      }
    }

+137 −1
Original line number Diff line number Diff line
@@ -7,6 +7,9 @@ use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RedirectDestinationTrait;
use Drupal\Core\Url;
use Drupal\rest\Plugin\views\style\Serializer;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * A style plugin for data export views.
@@ -31,6 +34,37 @@ class DataExport extends Serializer {
   */
  protected $defaultFieldLabels = TRUE;

  /**
   * The module handler service.
   *
   * @var \Drupal\Core\Extension\ModuleHandler
   */
  protected $moduleHandler;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('serializer'),
      $container->getParameter('serializer.formats'),
      $container->getParameter('serializer.format_providers'),
      $container->get('module_handler')
    );
  }

  /**
   * Constructs a Plugin object.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, SerializerInterface $serializer, array $serializer_formats, array $serializer_format_providers, $module_handler) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer, $serializer_formats, $serializer_format_providers);

    $this->moduleHandler = $module_handler;
  }

  /**
   * {@inheritdoc}
   */
@@ -51,6 +85,11 @@ class DataExport extends Serializer {
      'output_header' => ['default' => TRUE],
    ];

    $options['xml_settings']['contains'] = [
      'root_node_name' => ['default' => 'response'],
      'item_node_name' => ['default' => 'item'],
    ];

    return $options;
  }

@@ -143,8 +182,61 @@ class DataExport extends Serializer {
            ],
          ];
        }

        if (in_array('xml', $format_options)) {
          $form['xml_settings'] = [
            '#type' => 'details',
            '#open' => TRUE,
            '#title' => $this->t('XML settings'),
            '#tree' => TRUE,
            '#states' => [
              'visible' => [
                ':input[name="style_options[formats]"]' => [
                  ['value' => 'xml'],
                ],
              ],
            ],
          ];

          $xml_options = $this->options['xml_settings'];
          // Add our default options for backwards compatibility.
          $xml_options += [
            'root_node_name' => 'response',
            'item_node_name' => 'item',
          ];
          $xml_tag_regex = '[A-Za-z_][A-Za-z0-9_.:\\-]*';
          $form['xml_settings']['root_node_name'] = [
            '#type' => 'textfield',
            '#title' => $this->t('Root node name'),
            '#description' => $this->t('This should be a valid XML node name.'),
            '#default_value' => $xml_options['root_node_name'],
            '#pattern' => $xml_tag_regex,
            '#states' => [
              'required' => [
                ':input[name="style_options[formats]"]' => [
                  ['value' => 'xml'],
                ],
              ],
            ],
          ];
          $form['xml_settings']['item_node_name'] = [
            '#type' => 'textfield',
            '#title' => $this->t('Item node name'),
            '#description' => $this->t('This should be a valid XML node name.'),
            '#default_value' => $xml_options['item_node_name'],
            '#pattern' => $xml_tag_regex,
            '#states' => [
              'required' => [
                ':input[name="style_options[formats]"]' => [
                  ['value' => 'xml'],
                ],
              ],
            ],
          ];
        }
        break;
    }

  }

  /**
@@ -243,7 +335,7 @@ class DataExport extends Serializer {
    foreach ($this->view->result as $row_index => $row) {
      $this->view->row_index = $row_index;
      $output = $this->view->rowPlugin->render($row);
      \Drupal::moduleHandler()->alter('views_data_export_row', $output, $row, $this->view);
      $this->moduleHandler->alter('views_data_export_row', $output, $row, $this->view);
      $rows[] = $output;
    }

@@ -264,10 +356,54 @@ class DataExport extends Serializer {
    if ($format === 'csv' && $this->options['csv_settings']['use_serializer_encode_only'] == 1) {
      return $this->serializer->encode($rows, $format, ['views_style_plugin' => $this]);
    }

    if ($format === 'xml') {
      return $this->renderXmlStyle($rows);
    }
    else {
      return $this->serializer->serialize($rows, $format, ['views_style_plugin' => $this]);
    }

  }

  /**
   * Renders the style using the XML format.
   *
   * @param array $rows
   *   The rows to render.
   *
   * @return string
   *   The rendered style.
   */
  protected function renderXmlStyle(array $rows) {
    $options = $this->options['xml_settings'] ?? [];
    // Add our default options for backwards compatibility.
    $options += [
      'root_node_name' => 'response',
      'item_node_name' => 'item',
    ];
    $context = [
      'views_style_plugin' => $this,
    ];

    if ($options['root_node_name']) {
      // @todo We can rely on the constant being defined once we drop support for Drupal 9.
      $context[defined('XmlEncoder::ROOT_NODE_NAME') ? XmlEncoder::ROOT_NODE_NAME : 'xml_root_node_name'] = $options['root_node_name'];
    }

    $item_node_name = $options['item_node_name'] ?: 'item';
    if ($item_node_name != 'item') {
      $new_rows = [];
      foreach ($rows as $k => $row) {
        $new_rows[$item_node_name][] = [
          '@key' => $k,
          '#' => $row,
        ];
      }
      $rows = $new_rows;
    }

    return $this->serializer->serialize($rows, 'xml', $context);
  }

}
+219 −0
Original line number Diff line number Diff line
langcode: en
status: true
dependencies:
  module:
    - rest
    - user
    - node
id: test_xml_export
label: 'Test serialize display entity rows to XML'
module: views_data_export
description: ''
tag: ''
base_table: node_field_data
base_field: nid
core: 8.x
display:
  default:
    display_plugin: default
    id: default
    display_title: Master
    position: 0
    display_options:
      access:
        type: perm
        options:
          perm: 'access content'
      cache:
        type: tag
        options: { }
      query:
        type: views_query
        options:
          disable_sql_rewrite: false
          distinct: false
          replica: false
          query_comment: ''
          query_tags: { }
      exposed_form:
        type: basic
        options:
          submit_button: Apply
          reset_button: false
          reset_button_label: Reset
          exposed_sorts_label: 'Sort by'
          expose_sort_order: true
          sort_asc_label: Asc
          sort_desc_label: Desc
      pager:
        type: mini
        options:
          items_per_page: 10
          offset: 0
          id: 0
          total_pages: null
          expose:
            items_per_page: false
            items_per_page_label: 'Items per page'
            items_per_page_options: '5, 10, 25, 50'
            items_per_page_options_all: false
            items_per_page_options_all_label: '- All -'
            offset: false
            offset_label: Offset
          tags:
            previous: ‹‹
            next: ››
      style:
        type: default
        options:
          grouping: { }
          row_class: ''
          default_row_class: true
          uses_fields: false
      row:
        type: fields
        options:
          inline: { }
          separator: ''
          hide_empty: false
          default_field_elements: true
      fields:
        title:
          id: title
          table: node_field_data
          field: title
          entity_type: node
          entity_field: title
          label: ''
          alter:
            alter_text: false
            make_link: false
            absolute: false
            trim: false
            word_boundary: false
            ellipsis: false
            strip_tags: false
            html: false
          hide_empty: false
          empty_zero: false
          settings:
            link_to_entity: false
          plugin_id: field
          relationship: none
          group_type: group
          admin_label: ''
          exclude: false
          element_type: ''
          element_class: ''
          element_label_type: ''
          element_label_class: ''
          element_label_colon: true
          element_wrapper_type: ''
          element_wrapper_class: ''
          element_default_classes: true
          empty: ''
          hide_alter_empty: true
          click_sort_column: value
          type: string
          group_column: value
          group_columns: { }
          group_rows: true
          delta_limit: 0
          delta_offset: 0
          delta_reversed: false
          delta_first_last: false
          multi_type: separator
          separator: ', '
          field_api_classes: false
      filters:
        status:
          value: '1'
          table: node_field_data
          field: status
          plugin_id: boolean
          entity_type: node
          entity_field: status
          id: status
          expose:
            operator: ''
          group: 1
      sorts:
        created:
          id: created
          table: node_field_data
          field: created
          order: DESC
          entity_type: node
          entity_field: created
          plugin_id: date
          relationship: none
          group_type: group
          admin_label: ''
          exposed: false
          expose:
            label: ''
          granularity: second
      header: { }
      footer: { }
      empty: { }
      relationships: { }
      arguments: { }
      display_extenders: { }
    cache_metadata:
      max-age: -1
      contexts:
        - 'languages:language_content'
        - 'languages:language_interface'
        - url.query_args
        - 'user.node_grants:view'
        - user.permissions
      tags: { }
  xml_defaults:
    display_plugin: data_export
    id: xml_defaults
    display_title: serializer
    position: null
    display_options:
      path: test/data_export/xml/defaults
      filename: something.xml
      style:
        type: data_export
        options:
          formats:
            json: xml
      displays:
        page_1: page_1
        default: '0'
  xml_change_node_names:
    display_plugin: data_export
    id: xml_change_node_names
    display_title: xml
    position: null
    display_options:
      path: test/data_export/xml/change_node_names
      style:
        type: data_export
        options:
          formats:
            xml: xml
          xml_settings:
            root_node_name: nodes
            item_node_name: node
      displays:
        page_1: page_1
        default: '0'
  page_1:
    display_plugin: page
    id: page_1
    display_title: page
    position: null
    display_options:
      defaults:
        access: false
        style: false
        row: false
      style:
        type: default
      row:
        type: entity:node
      path: test/data_export/page
+146 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\views_data_export\Functional;

use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\search_api\Functional\ExampleContentTrait;
use Drupal\Tests\views\Functional\ViewTestBase;

/**
 * Tests views data export with batch.
 *
 * @group views_data_export
 */
class ViewsDataExportXmlTest extends ViewTestBase {

  use NodeCreationTrait;
  use ContentTypeCreationTrait;
  use ExampleContentTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'node',
    'file',
    'rest',
    'serialization',
    'user',
    'views',
    'views_data_export',
    'views_data_export_test',
  ];

  /**
   * {@inheritdoc}
   */
  public static $testViews = [
    'test_xml_export',
  ];

  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

  /**
   * {@inheritdoc}
   */
  protected function setUp($import_test_views = TRUE, $modules = ['views_test_config']):void {
    parent::setUp($import_test_views, ['views_data_export_test']);
    $this->createContentType([
      'type' => 'page',
    ]);
    $account = $this->drupalCreateUser(['access content']);
    $this->drupalLogin($account);
  }

  /**
   * Data provider for XML response tests.
   */
  public static function emptyXmlResponseProvider(): array {
    return [
      'default root' => [
        'test/data_export/xml/defaults',
        <<<XML
<?xml version="1.0"?>
<response/>
XML,
      ],
      'custom root' => [
        'test/data_export/xml/change_node_names',
        <<<XML
<?xml version="1.0"?>
<nodes/>
XML,
      ],
    ];
  }

  /**
   * Test XML responses.
   *
   * @dataProvider emptyXmlResponseProvider
   */
  public function testEmptyXmlResponse(string $path, string $expected_xml): void {
    $this->drupalGet($path);
    $this->assertSession()->statusCodeEquals(200);
    $actual_xml = $this->getSession()->getPage()->getContent();

    $this->assertSame(
      $expected_xml,
      $actual_xml,
      "The XML output matches for $path."
    );
  }

  /**
   * Data provider for XML response tests.
   */
  public static function normalXmlResponseProvider(): array {
    return [
      'default root' => [
        'test/data_export/xml/defaults',
        <<<XML
<?xml version="1.0"?>
<response><item key="0"><title>page node title: 5</title></item><item key="1"><title>page node title: 4</title></item><item key="2"><title>page node title: 3</title></item><item key="3"><title>page node title: 2</title></item><item key="4"><title>page node title: 1</title></item><item key="5"><title>page node title: 0</title></item></response>
XML,
      ],
      'custom root' => [
        'test/data_export/xml/change_node_names',
        <<<XML
<?xml version="1.0"?>
<nodes><node key="0"><title>page node title: 5</title></node><node key="1"><title>page node title: 4</title></node><node key="2"><title>page node title: 3</title></node><node key="3"><title>page node title: 2</title></node><node key="4"><title>page node title: 1</title></node><node key="5"><title>page node title: 0</title></node></nodes>
XML,
      ],
    ];
  }

  /**
   * Test XML responses.
   *
   * @dataProvider normalXmlResponseProvider
   */
  public function testNormalXmlResponse(string $path, string $expected_xml): void {
    foreach (range(0, 5) as $i) {
      $this->createNode([
        'status' => TRUE,
        'type' => 'page',
        'title' => 'page node title: ' . $i,
        'created' => 280304046 + $i * 43200,
      ]);
    }

    $this->drupalGet($path);
    $this->assertSession()->statusCodeEquals(200);
    $actual_xml = $this->getSession()->getPage()->getContent();

    $this->assertSame(
      $expected_xml,
      $actual_xml,
      "The XML output matches for $path."
    );
  }

}