• Час читання ~7 хв
  • 06.07.2023

Нещодавно у мене виникла потреба заповнити доступні параметри компонента множинного вибору значеннями з віддаленого API. ChoicesJS підтримує це, хоча документація залишає бажати кращого. Таким чином, нова публікація, в якій детально описується, як я зробив свою динамічну роботу з мультивибором, здавалася важливою.

Що саме я маю на увазі під "динамічним мультивибором", запитаєте ви? Простіше кажучи, це компонент вибору, що дозволяє кілька варіантів, параметри яких завантажуються - динамічно - з іншого місця, а не попередньо завантажені наперед.

Сценарій тут полягає в тому, що мої користувачі повинні мати можливість вибрати один або кілька ресурсів, але вони можуть мати сотні або тисячі у списку доступних ресурсів, тому про завантаження всіх параметрів заздалегідь, щоб заповнити список вибраних параметрів, не могло бути й мови. Це, звичайно, значно спростило б речі.

Отже, який же бажаний функціонал, і як ви його створюєте?

Я радий, що ви запитали. Бажана поведінка полягає в тому, що коли користувач вводить свій пошуковий термін у поле введення, яке представляє нам ChoicesJS, ми хочемо ініціювати виклик API, який отримає відповідні ресурси та заповнить наш список доступних варіантів, дозволяючи їм зробити один або кілька варіантів. Потім за бажанням вони можуть знову здійснювати пошук, вибираючи додаткові параметри тощо.

Оскільки ми використовуємо Livewire, вся взаємодія з API відбуватиметься на стороні сервера, краса цього полягає в тому, що жодні облікові дані не потрібно надавати клієнтській стороні, і будь-які важкі маніпуляції з даними також відбуватимуться на стороні сервера.

Гаразд, перш за все, нам потрібен компонент

<?php
namespace App\Http\Livewire;
use Livewire\Component;
use MyApi\Client;
class Select extends Component
{
  public $options = [];
  public $selections = [];
  public function render()
  {
    return view('livewire.select');
  }
}

livewire Це основи, але нам також потрібно написати метод виклику, коли користувач виконує пошук:

public search($term)
{
  $results = Client::search($term);
  $preserve = collect($this->options)
                ->filter(fn ($option) =>
                    in_array(
                      $option['value'],
                      $this->selections
                    )
                  )
                ->unique();
  $this->options = collect($results)
                     ->map(fn ($item) => 
                             [
                               'label' => $item->name,
                               'value' => $item->id
                             ])
                     ->merge($preserve)
                     ->unique();
  $this->emit('select-options-updated', $this->options);
}

Що тут відбувається?

  1. We pass the entered search phrase to the search functionality of our api client. This could also be something as basic as a whereLike query against a laravel model.
  2. We look at the existing options, and pluck out any that are currently selected.
  3. We map the relevant data from the search results into the format expected by our ChoicesJS widget, and then merge in the options that we grabbed in the previous step.
  4. Emit an event so that the JS widget can update its options list.

Досить просто... Але навіщо крок 2, і об'єднання його результатів в результати пошукового запиту?

Ну, оскільки те, як у нас є вибір, він не знає про варіанти, яких зараз немає у списку доступних варіантів. Ви, ймовірно, могли б зберігати весь об'єкт там, якби дійсно хотіли, але тоді ви б тягнули набагато більше даних про наступні виклики запиту / відповіді... І це досить легко - і акуратно.

Це сторона PHP відсортована, тепер переходимо до перегляду

@props([
  'options' => [],
])
@once
  @push('css')
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css" />
  @endpush
  @push('js')
    <script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
  @endpush
@endonce
<div x-data="{}" x-init="">
  <select x-ref="select"></select>
</div>

Там наша основна структура поглядів вирізана. Зверніть увагу на використання @once, і @push для того, щоб заштовхнути необхідні css & js у відповідні стеки, і переконайтеся, що вони додаються лише один раз, тому, якби у вас було кілька екземплярів цього компонента на вашій сторінці, ви б не забруднювали DOM декількома копіями одного і того ж скрипта. Славного. Це всі маленькі тонкощі, подібні до цієї, змушують мене любити Ларавеля ...

Гаразд, отже, ми завантажуємо сценарій і стилі вибору, тепер нам потрібно вказати їх на наш самотній маленький елемент вибору і змусити його піти. По одній штуці за раз тут..

Дозволяє конкретизувати x-дані

x-data="{
  value: @entangle('selections'),
  options: {{ json_encode($options) }},
  debounce: null,
}"
  • Значення заплутується, зв'язується зі станом livewire.
  • Параметри заповнюються будь-якими попередньо визначеними значеннями, які ми могли встановити за замовчуванням у нашому компоненті PHP
  • debounce буде використовуватися як ціль для відстеження setTimeout() для відхилення введення користувачем, вам насправді не потрібно його визначати, але для ясності я включив його.

Тепер м'ясо реалізації, x-init

x-init="this.$nextTick(() => {
  const choices = new Choices(this.$refs.select, {
    removeItems: true,
    removeItemButton: true,
    duplicateItemsAllowed: false,
  })
  const refreshChoices = () => {
    const selection = this.value
    choices.clearStore()
    choices.setChoices(this.options.map(({ value, label }) => ({
      value,
      label,
      selected: selection.includes(value),
    })))
  }

  this.$refs.select.addEventListener('change', () => {
    this.value = choices.getValue(true)
  })
  this.$refs.select.addEventListener('search', async (e) => {
    if (e.detail.value) {
      clearTimeout(this.debounce)
      this.debounce = setTimeout(() => {
        $wire.call('search', e.detail.value)
      }, 300)
    }
  })
  $wire.on('select-options-updated', (options) => {
    this.options = options
  })
  this.$watch('value', () => refreshChoices())
  this.$watch('options', () => refreshChoices())
  refreshChoices()
})"

Гаразд, там відбувається купа, але нічого страшенно складного.

  1. We instantiate an instance of ChoicesJS, pointing at the select element we previously created by it's x-ref value 'select', with some basic config.
  2. Create a function that will refresh the available options whenever we call it, iterating through options and setting selected to true for any items whose value is in our selections wire model bucket.
  3. Set up an event listener to sync the selections whenever a change event fires.
  4. Set up an event listener to call our PHP search method, debounced with a 300ms timeout.
  5. Set up a listener for the livewire event that we emit when we've updated the options on the server side, and accordingly update them on the client side.
  6. Set up watchers that call our refresh function when the selections or options have changed.
  7. Finally, fire the refresh function to setup the initial state of the choicesJS widget

Це все загортається в a $nextTick тому, що розмітка та бібліотеки JS / тощо повинні бути на сторінці, перш ніж вона буде працювати.

Я не можу віддати належне всьому цьому, Калеб красиво описав, як працювати з бібліотекою choicesJS разом з alpine в інтеграційному записі на сайті alpineJS. Але пошук і біти $wire.on - це все я.

Ось і все. Коли ви вводите віджет choicesJS, він потрапляє в клієнт API і отримує результати, ці результати заповнюються в списку виборів. Коли ви робите вибір, він працюватиме так само, якби він був частиною попередньо заповненого списку. Коли ви запускаєте новий пошук, раніше вибрані елементи будуть об'єднані з новими результатами пошуку, щоб попередні вибори залишалися видимими у віджеті choicesJS.

Ви, напевно, помітили, що немає дроту:модель, визначена на елементі вибору... Ну, це тому, що ми керуємо державою з Alpine, тому це не потрібно. choicesJS все одно замінить цей елемент вибору.

Наш останній компонент livewire та перегляд

Livewire PHP компонент Перегляд

<?php
namespace App\Http\Livewire;
use Livewire\Component;
use MyApi\Client;
class Select extends Component
{
  public $options = [];
  public $selections = [];
  public function render()
  {
    return view('livewire.select');
  }
  public search($term)
  {
    $results = Client::search($term);
    $preserve = collect($this->options)
                  ->filter(fn ($option) => 
                    in_array(
                      $option['value'],
                      $this->selections
                    )
                  )
                  ->unique();
    $this->options = collect($results)
                       ->map(fn ($item) =>
                         [
                           'label' => $item->name,
                           'value' => $item->id
                         ])
                       ->merge($preserve)
                       ->unique();
    $this->emit('select-options-updated', $this->options);
  }
}
@props([
 'options' => [],
])
@once
  @push('css')
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css" />
  @endpush
  @push('js')
    <script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
  @endpush
@endonce

<div
  wire:ignore
  x-data="{
    value: @entangle('selections'),
    options: {{ json_encode($options) }},
    debounce: null,
  }"
  x-init="
    this.$nextTick(() => {
      const choices = new Choices(this.$refs.select, {
        removeItems: true,
        removeItemButton: true,
        duplicateItemsAllowed: false,
     })
     const refreshChoices = () => {
       const selection = this.value
  
       choices.clearStore()
       choices.setChoices(this.options.map(({ value, label }) => ({
         value,
         label,
         selected: selection.includes(value),
       })))
     }
     this.$refs.select.addEventListener('change', () => {
       this.value = choices.getValue(true)
     })
     this.$refs.select.addEventListener('search', async (e) => {
       if (e.detail.value) {
         clearTimeout(this.debounce)
         this.debounce = setTimeout(() => {
           $wire.call('search', e.detail.value)
         }, 300)
       }
     })
     $wire.on('select-options-updated', (options) => {
       this.options = options
     })
     this.$watch('value', () => refreshChoices())
     this.$watch('options', () => refreshChoices())
     refreshChoices()
   })">
  <select x-ref="select"></select>
</div>

Заключні думки

Цей компонент можна і, ймовірно, слід очистити, щоб бути більш багаторазовим. Зробити подію прослуханою можна визначити, оскільки реквізит переходить у компонент леза тощо, але це повинно дати вам достатньо огляду, щоб щось подібне працювало над вашим проектом.

Як завжди, величезна подяка спільноті, яка робить все це можливим. Laravel, Livewire, AlpineJS і Tailwind дійсно роблять розробку мрією - я величезний шанувальник стека TALL!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Про автора CrazyBoy49z
WORK EXPERIENCE
Контакти
Ukraine, Lutsk
+380979856297