Loading README.md +107 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading @@ -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 Loading modules/sfc_example/components/actions/example_actions_form.sfc 0 → 100644 +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', ''), ]; }; modules/sfc_example/components/actions/example_actions_render.sfc 0 → 100644 +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, ], ])); }; modules/sfc_example/components/actions/example_actions_todo.sfc 0 → 100644 +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; }; modules/sfc_example/components/actions/example_actions_todo_alpine.sfc 0 → 100644 +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
README.md +107 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading @@ -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 Loading
modules/sfc_example/components/actions/example_actions_form.sfc 0 → 100644 +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', ''), ]; };
modules/sfc_example/components/actions/example_actions_render.sfc 0 → 100644 +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, ], ])); };
modules/sfc_example/components/actions/example_actions_todo.sfc 0 → 100644 +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; };
modules/sfc_example/components/actions/example_actions_todo_alpine.sfc 0 → 100644 +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;