Commit d130f56e authored by Samuel Mortenson's avatar Samuel Mortenson Committed by Sam Mortenson
Browse files

Issue #3278255 by samuel.mortenson: Support "actions" to run backend code.

parent d00a86a0
Loading
Loading
Loading
Loading
+107 −0
Original line number Diff line number Diff line
@@ -57,6 +57,8 @@ $library = []; // A library definition if your CSS/JS are in different files.
$buildContextForm = function (array $form, FormStateInterface $form_state, array $default_values = []) {}
$validateContextForm = function (array $form, FormStateInterface $form_state) {}
$submitContextForm = function (array $form, FormStateInterface $form_state) {}
// Actions for this component.
$actions[...] = function () {}
```

## Using your component
@@ -193,6 +195,11 @@ $(this).on('click', function () {
})(jQuery, Drupal, drupalSettings);
```

If you want to avoid writing out `data-sfc-id="..."` in all your components,
you can use `{{ sfc_attributes }}` instead. This includes that data attribute
as well as `data-sfc-unique-id`, which is a string that is unique to this
render of the component.

Drupal Core has recently started moving away from jQuery, so if you'd like to
use vanilla JS with your attachments (wrapping them in "once()" instead of
"jQuery.once()") you can add the "data-vanilla" attribute to your script tag.
@@ -212,6 +219,106 @@ Note that when using vanilla JS, "element" refers to your current element, not
</script>
```

## Adding backend behavior with actions

If your component needs to take some action in the backend - for example submit
a form or make an AJAX call, you can use actions.

Actions are callbacks that function like Controllers, returning either a
Symfony response or a render array, and are very useful for small pieces of
functionality.

To define an action, set `$actions['action_name']` in your `*.sfc` file to a
function. Your Twig template will then have `sfc_actions.action_name` in its
context, which is a URL to your action. You can use this URL for form
actions, AJAX links (`<a href="..." class="use-ajax">), or however else you
would normally use a link to a custom Controller in Drupal.

Here's an example where a component takes in a form submit and reflects input
back to the user:

```php
<template>
  <form id="example-actions-form" action="{{ sfc_actions.submit }}" method="post">
    <label for="example-actions-text">Text</label>
    <input type="text" name="text" id="example-actions-text" required />
    <input type="submit" value="Submit" />
  </form>
</template>

<?php

use Symfony\Component\HttpFoundation\Request;

$actions['submit'] = function (Request $request) {
  return [
    '#plain_text' => 'You submitted: ' . $request->request->get('text', ''),
  ];
};
```

You may notice this action takes in the current request as an argument - see
the "Autowiring" section below for details on how that works.

In a real implementation, "submit" would likely write to the database and
perform a redirect to another page. Action URLs aren't the most user-friendly
thing to see. For more examples, see the sfc_example module.

### Action security

All action URLs require CSRF tokens, which require users to have a session. If
you want to let anonymous users perform actions, you will need to start a
session for them. The `ComponentController` supports this if you pass
`anon_session: 'TRUE'` in the `defaults` section of your route.

Beyond all that, you are responsible for performing access checking in your
action functions.

### Making AJAX easier to handle

Action URLs also come with the query param `sfc_unique_id`, which is useful
for AJAX callbacks if you are also using `{{ sfc_attributes }}` in your
template. In your callback you can do something like:

```php
use Drupal\Core\Ajax\AjaxResponse;
use Symfony\Component\HttpFoundation\Request;

$actions['do_ajax'] = function (Request $request) {
  $unique_id = (string) $request->query->get('sfc_unique_id', '');
  $selector = '[data-sfc-unique-id="' . $unique_id . '"]';
  $response = new AjaxResponse();
  // Add commands that target $selector here...
  return $response;
};
```

And know that you are targeting the correct instance of this component.

## Autowiring / dependency injection in callbacks

Any callback in a `*.sfc` file can take advantage of dependency injection
instead of calling `\Drupal::service()`. To do so, add an argument with the
class or interface you want to have access to, and the plugin deriver will do
all the work to figure out what service best matches that argument.

For example:

```php
use Drupal\Core\Session\AccountProxyInterface;

$prepareContext = function (array &$context, AccountProxyInterface $current_user) {
  $context['user_id'] = $current_user->id();
}
```

Would inject the `current_user` service as the second argument. Some services
implement the same interface or have the same class. In those cases, if you
match the ID of the service exactly, replacing `.` with `_`, the correct
service will be injected. For example, `\Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyvalue`
will match the `keyvalue` service even though many other services implement
that interface.

## Component caching

Cache metadata for components is collected/bubbled when their template is
+22 −0
Original line number Diff line number Diff line
<!--
  This component uses actions to display text back to the user, using a simple
  form and render array response.
-->

<template>
  <form id="example-actions-form" action="{{ sfc_actions.submit }}" method="post">
    <label for="example-actions-text">Text</label>
    <input type="text" name="text" id="example-actions-text" required />
    <input type="submit" value="Submit" />
  </form>
</template>

<?php

use Symfony\Component\HttpFoundation\Request;

$actions['submit'] = function (Request $request) {
  return [
    '#plain_text' => 'You submitted: ' . $request->request->get('text', ''),
  ];
};
+32 −0
Original line number Diff line number Diff line
<!--
  This component uses actions to re-render itself when the "Roll again" link is
  clicked. Because of core's AJAX APIs, no JavaScript is needed.
-->

<template>
  <div {{ sfc_attributes }}>
    <div>You rolled {{ random(0, 20) }}!</div>
    <a href="{{ sfc_actions.render }}" class="use-ajax">
      Roll again
    </a>
  </div>
  {{ attach_library('core/drupal.ajax') }}
  {{ sfc_cache(0, 'max-age') }}
</template>

<?php

use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Symfony\Component\HttpFoundation\Request;

$actions['render'] = function (Request $request) {
  $unique_id = (string) $request->query->get('sfc_unique_id', '');
  return (new AjaxResponse())->addCommand(new ReplaceCommand('[data-sfc-unique-id="' . $unique_id . '"]', [
    '#type' => 'sfc',
    '#component_id' => 'example_actions_render',
    '#context' => [
      'sfc_unique_id' => $unique_id,
    ],
  ]));
};
+122 −0
Original line number Diff line number Diff line
<!--
  This complicated example provides an AJAX todo list that uses actions for
  adding, removing, and re-ordering list items. For a much simpler example, see
  ./example_actions_form.sfc.

  A lot of the complexity is because AJAX is used, and could be removed if the
  entire list was re-rendered every time. For a more JavaScript-heavy example,
  see ./example_actions_todo.alpine.sfc
-->

<template>
  <div {{ sfc_attributes }}>
    <h2>Todo list</h2>
    <ul>
      {% for item in items %}
        {% include 'sfc--example-actions-todo-item.html.twig' with {'item': item} %}
      {% endfor %}
    </ul>
    <form id="{{ sfc_unique_id }}-form" action="{{ sfc_actions.add }}">
      <div>
        <label for="{{ sfc_unique_id }}-text">Item text</label>
        <input type="text" name="text" id="{{ sfc_unique_id }}-text" required />
      </div>
      <input type="submit" value="Add" class="use-ajax-submit" />
    </form>
  </div>
  {{ attach_library('core/drupal.ajax') }}
  {{ attach_library('core/jquery.form') }}
  {{ sfc_cache('example-todo-' ~ user_id) }}
</template>

<?php

use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Ajax\AfterCommand;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\AppendCommand;
use Drupal\Core\Ajax\BeforeCommand;
use Drupal\Core\Ajax\InvokeCommand;
use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\HttpFoundation\Request;

$prepareContext = function (array &$context, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user) {
  $context['items'] = todo_get_state($keyvalue, $current_user);
  $context['user_id'] = $current_user->id();
};

$actions['add'] = function (Request $request, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user, UuidInterface $uuid) {
  $text = (string) $request->request->get('text', '') ?: '(Empty)';
  $unique_id = (string) $request->query->get('sfc_unique_id', '');
  $items = todo_get_state($keyvalue, $current_user);
  $item = [
    'id' => $uuid->generate(),
    'text' => $text,
    'weight' => count($items),
  ];
  $items[] = $item;
  todo_set_state($keyvalue, $current_user, $items);
  $build = [
    '#type' => 'sfc',
    '#component_id' => 'example_actions_todo_item',
    '#context' => [
      'item' => $item,
    ],
  ];
  return (new AjaxResponse())
    ->addCommand(new AppendCommand('[data-sfc-unique-id="' . $unique_id . '"] ul', $build))
    ->addCommand(new InvokeCommand('#' . $unique_id . '-text', 'val', ['']));
};

$actions['remove'] = function (Request $request, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user) {
  $item_id = (string) $request->query->get('item_id', '');
  $items = todo_get_state($keyvalue, $current_user);
  foreach ($items as $i => $item) {
    if ($item['id'] === $item_id) {
      unset($items[$i]);
      break;
    }
  }
  todo_set_state($keyvalue, $current_user, array_values($items));
  return (new AjaxResponse())->addCommand(new RemoveCommand('[data-todo-item-id="' . $item_id . '"]'));
};

$actions['move'] = function (Request $request, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user) {
  $item_id = (string) $request->query->get('item_id', '');
  $direction = $request->query->get('direction', '');
  $items = todo_get_state($keyvalue, $current_user);
  $swap_id = FALSE;
  foreach ($items as $i => $item) {
    if ($item['id'] === $item_id) {
      $new_index = $direction === 'up' ? $i - 1 : $i + 1;
      if (isset($new_index)) {
        $swap_id = $items[$new_index]['id'];
        $items[$i] = $items[$new_index];
        $items[$new_index] = $item;
      }
      break;
    }
  }
  if (!$swap_id) {
    return new AjaxResponse();
  }
  todo_set_state($keyvalue, $current_user, $items);
  $response = new AjaxResponse();
  $response->addCommand(new RemoveCommand('[data-todo-item-id="' . $item_id . '"]'));
  $build = [
    '#type' => 'sfc',
    '#component_id' => 'example_actions_todo_item',
    '#context' => [
      'item' => $item,
    ],
  ];
  if ($direction === 'up') {
    $response->addCommand(new BeforeCommand('[data-todo-item-id="' . $swap_id . '"]', $build));
  }
  else {
    $response->addCommand(new AfterCommand('[data-todo-item-id="' . $swap_id . '"]', $build));
  }
  return $response;
};
+97 −0
Original line number Diff line number Diff line
<!--
  In this version of the todo list, Alpine.js is used with only one action that
  updates the entire state of the form. Much simpler than using Drupal's AJAX
  API, but some security controls are missing (JSON schema validation).
-->

<template>
  <div x-data="alpineTodo({{ init_data|json_encode() }})" x-init="$watch('items', value => update(value))">>
    <h2>Todo list</h2>
    <ul>
      <template x-for="(item, index) in items">
        <li>
          <div x-show="!showForm[item.id]">
            <div x-text="item.text"></div>
            <a @click="showForm[item.id] = true">Edit</a>
            <a @click="items.splice(index, 1)">Remove</a>
            <a @click="swap(index, index-1)" x-show="index > 0">Move up</a>
            <a @click="swap(index, index+1)" x-show="index < items.length-1">Move down</a>
          </div>
          <form :id="$id('todo-form')" @submit.prevent="showForm[item.id] = false" x-show="showForm[item.id]">
            <div x-id="['todo-item-text']">
              <label :for="$id('todo-item-text')">Item text</label>
              <input type="text" name="text" :id="$id('todo-item-text')" :value="item.text" x-model.debounce="item.text" required />
            </div>
            <input type="submit" value="Save" />
          </form>
        </li>
      </template>
    </ul>
    <form id="todo-form" @submit.prevent="addItem">
      <div x-id="['todo-add-text']">
        <label for="$id('todo-add-text')">Item text</label>
        <input type="text" name="text" id="$id('todo-add-text')" required x-model="addText" />
      </div>
      <input type="submit" value="Add" />
    </form>
  </div>
  {{ attach_library('sfc_example/alpine') }}
  {{ sfc_cache('example-todo-' ~ user_id) }}
</template>

<script>
  function alpineTodo(init_data) {
    return {
      items: init_data.items || [],
      showForm: {},
      addText: '',
      swap: function(from, to) {
        var swap = this.items[to]
        this.items[to] = this.items[from]
        this.items[from] = swap
      },
      addItem: function () {
        this.items.push({
          id: Math.random().toString(36).substr(2, 9),
          text: this.addText,
        })
        this.addText =  ''
      },
      update: function() {
        fetch(init_data.updateUrl, {
          method: 'POST',
          body: JSON.stringify(this.items),
        })
      }
    }
  }
</script>

<?php

use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

$prepareContext = function (array &$context, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user) {
  $context['init_data'] = [
    'items' => todo_get_state($keyvalue, $current_user),
    'updateUrl' => $context['sfc_actions']['update']->setAbsolute()->toString(),
  ];
  $context['user_id'] = $current_user->id();
};

$actions['update'] = function (Request $request, KeyValueFactoryInterface $keyvalue, AccountProxyInterface $current_user, UuidInterface $uuid) {
  // Ideally would do a schema check here too.
  $items = json_decode($request->getContent(), TRUE);
  if (!$items) {
    throw new BadRequestHttpException('Unable to parse JSON');
  }
  todo_set_state($keyvalue, $current_user, $items);
  return new JsonResponse(['status' => 'ok']);
};

$library['header'] = TRUE;
Loading