Как здорово, что ты вернулся! В нашей предыдущей статье мы говорим об объединении различных моделей и определении отношений в нашем приложении Laravel. Самое приятное то, что теперь у каждого пользователя нашего проекта могут быть персонализированные задачи.

В этой новой статье мы сделаем еще один шаг вперед и исследуем связь многие-ко-многим. Мы представим совершенно новую модель под названием теги, которая позволит каждой задаче в нашем приложении иметь несколько тегов.

Но подождите, это еще не все! В этой статье мы также собираемся создать фантастический компонент колонки выбора поиска, предназначенный для работы с тегами внутри наших форм. Так что будьте готовы, потому что мы собираемся погрузиться в некоторые действительно интересные вещи!

Создайте ввод выбора поиска в колонке

Теперь давайте поговорим о новой функции, которую я решил добавить: поле выбора поиска для добавления и выбора тегов в формах задач. Возможно, в приложении существуют пакеты, которые могут помочь в этом, но давайте начнем с реализации на чистом JavaScript.

Итак, начнем!

Сначала я создал простой компонент ввода для поиска текста, расположенный в общем каталоге наших блейд-компонентов. Затем я добавил этот входной компонент в наше представление создания задач, как показано на следующем рисунке:

Теперь, если мы хотим работать с чистым JavaScript внутри нашего общего ввода, нам обычно необходимо определить все функции JavaScript в нашем каталоге app.js inside resources/js. Однако я предпочитаю более удобный способ сделать это — использовать составные скрипты. Для этого у нас есть еще одна полезная функция в синтаксисе блейда, называемая push. Вы можете использовать его следующим образом:

@push('scripts')
<script type="module">
    // Your JavaScript code goes here
</script>
@endpush

Используя этот синтаксис, вы можете определить свой скрипт внутри тегов @push('scripts') и @endpush, и он будет рассматриваться как модуль. Это упрощает управление и организацию кода JavaScript в вашем приложении.

Однако сейчас эти скрипты не будут работать. Чтобы воплотить их в жизнь, нам нужно добавить сложенные скрипты внутри нашего основного макета:

Теперь, если вы откроете консоль в браузере, вы увидите, что наш скрипт работает:

Прямо сейчас первым шагом является прослушивание каждого изменения и символа, который пользователь вводит в поле ввода. Для этого вам необходимо назначить прослушиватель событий элементу ввода.

Чтобы увидеть это в действии, вы можете добавить следующий фрагмент кода в свои сценарии. Запустив его, вы заметите, что каждый символ, который вы вводите во входные данные, будет записан на вашу консоль:

@push('scripts')
<script type="module">
    let tagSearchInput = document.getElementById("search-tag");

    tagSearchInput.addEventListener("input", async function (e) {
        console.log(tagSearchInput.value);
    });
</script>
@endpush

Теперь давайте перейдем к следующему шагу — поиску всех тегов на основе входных данных поиска.

Но подождите, мы даже не определяем модель тегов внутри нашего приложения! На самом деле это очень просто! Все, что вам нужно сделать, это запустить следующую команду, и она позаботится о создании модели, миграции и контроллера за вас:

php artisan make:model Tag -mc

Эта модель проста, поскольку имеет только один атрибут — имя. Итак, обновите файл миграции, добавив столбец простого имени. Аналогичным образом обновите файл запроса, как показано ниже:

Наконец, давайте вернемся к контроллеру, потому что именно здесь происходят новые вещи, и нам нужно над этим работать.

Во-первых, давайте воспользуемся нашей индексной функцией немного по-другому. Вместо того, чтобы просто перечислять все теги в представлении, мы изменим его, чтобы он возвращал ответ в формате JSON. Это позволит нам получить все данные в нашем коде JavaScript:

public function index()
{
   return response()->json([
        'tags' => []
   ]);
}

Затем нам нужно выполнить поиск внутри наших записей тегов, чтобы найти теги на основе входных данных поиска. Для этого мы можем использовать широко используемый синтаксис в Laravel для поиска внутри модели. Laravel предоставляет полезную функцию под названием where(), которая позволяет нам извлекать определенные записи из таблиц нашей базы данных на основе определенных атрибутов или столбцов.

Например, если мы хотим найти тег с именем «job», запрос будет выглядеть так:

$tag = Tag::where('name', 'job')->get();

Не забудьте использовать get() в конце для выполнения команды SQL.

А что, если мы хотим найти все теги, имена которых содержат указанную букву или буквы? Здесь мы используем LIKE в качестве второго параметра и `%` в начале и конце нашего ключа поиска. Например:

# all tags start with a 's'
$tags = Tag::where('name', 'LIKE', 's%')->get();

# all tags end with a 's'
$tags = Tag::where('name', 'LIKE', 's%')->get();

# all tags contains a 's'
$tags = Tag::where('name', 'LIKE', '%s%')->get();

Итак, наша индексная функция может выглядеть следующим образом:

public function index()
{
    $query = Tag::where('name', 'LIKE', "%" . request('name') . "%")->get();
    return response()->json($query);
}

Вот и все! Используя этот синтаксис, мы можем легко искать и извлекать нужные теги из нашей базы данных.

Теперь давайте определим новый маршрут:

Route::get('tags', [TagController::class, 'index'])->name('tags.search');

Как вы видите на следующем рисунке, я просто добавляю несколько примеров данных и делаю запрос:

Что ж, давайте посмотрим, как мы можем использовать его в JavaScript. Чтобы сделать HTTP-запрос, мы можем использовать популярный пакет под названием Axios. Хорошей новостью является то, что Axios уже установлен в Laravel по умолчанию. Однако, если у вас его нет, вы можете легко установить его с помощью следующей команды:

npm install axios

После того, как вы настроили Axios, давайте вернемся к нашему коду JavaScript и выполним запрос:

let tagSearchInput = document.getElementById("search-tag");

tagSearchInput.addEventListener("input", async function (e) {
    const response = await axios.get(`/tags?name=${tagSearchInput.value}`);
    console.log(response.data);
});

Во-первых, позвольте мне объяснить, что означают в коде слова «async» и «await». Проще говоря, думайте о них как о слушателях, которые ждут результата запроса на определенной строке.

Теперь нам нужно отобразить список результатов нашего запроса. Для этого мы можем добавить элемент HTML и заполнить список всеми найденными элементами.

<script type="module">
    let tagSearchInput = document.getElementById("search-tag");
    let tagSearchList = document.getElementById("search-tag-list");

    tagSearchInput.addEventListener("input", async function (e) {
        const response = await axios.get(`/tags?name=${tagSearchInput.value}`);

        if (response.data.length) {
            tagSearchList.classList.remove("hidden");

            // remove all item inside list
            while (tagSearchList.firstChild) {
                tagSearchList.removeChild(tagSearchList.firstChild);
            }

            // add found tags inside the list
            response.data.forEach((tag) => {
                let li = document.createElement("li");
                li.appendChild(document.createTextNode(tag.name));
                li.className = "tag-list-item";
                // todo: uncomment after add tagSelected()
                // li.onclick = () => tagSelected(tag);
                tagSearchList.appendChild(li);
            });
        } else tagSearchList.classList.add("hidden");
    });
</script>

Этот процесс включает в себя перебор результатов. Если есть какие-либо данные, отображается наш список элементов, и мы добавляем список найденных тегов в наш список поиска.

Теперь нам нужно реализовать функцию щелчка для каждого элемента тега в списке. При нажатии на элемент тега его идентификатор тега должен быть добавлен в назначенное поле ввода. Это поле ввода имеет определенный идентификатор, например tags-select, и его цель — сохранить выбранные теги в нашей форме. Мы будем использовать это поле ввода при отправке выбранных тегов вместе с созданием задачи.

Для этого я добавил три HTML-элемента, как показано на рисунке. Первый элемент — это поле ввода с идентификатором tags-select. Второй элемент — это неупорядоченный список (ul), который служит родительским контейнером для перечисленных тегов результатов поиска. Третий элемент — это элемент div, который визуально представляет выбранные в данный момент теги.

Далее, когда пользователь нажимает на элемент тега в списке, мы хотим выполнить следующие действия:

  1. Создайте и добавьте элемент, представляющий выбранный тег, в tags-select-list. Кроме того, мы добавляем элемент кнопки закрытия, функциональность которого мы позже реализуем.
  2. Запустите другую функцию, которая обновляет наше поле ввода tags-select, добавляя идентификатор выбранного тега.
  3. Скройте раскрывающийся список, в котором отображаются результаты поиска.
  4. Наконец, очистите элемент ввода поиска тегов, чтобы подготовить его к добавлению другого тега.

Вот функция, которая обрабатывает событие щелчка для каждого перечисленного тега:

const tagSelected = (tag) => {
    let span = document.createElement("span");
    span.id = tag.id;
    span.className = "tag-select-item";

    let closeBtn = document.createElement("i");
    // todo: uncomment after add removeSelectedTag()
    // closeBtn.onclick = () => removeSelectedTag(span);
    closeBtn.appendChild(document.createTextNode('×'));
    
    span.appendChild(closeBtn);
    span.appendChild(document.createTextNode(tag.name));

    tagSelectWrap.appendChild(span);
    tagSearchList.classList.add("hidden");

    tagSearchInput.value = "";
    tagSelectInput.value = inputTag(parseInt(tag.id));
}

А это функция, которая обновляет наше поле ввода tags-select:

function inputTag(tagId) {
    let tags = tagSelectInput.value.length
        ? JSON.parse(tagSelectInput.value)
        : [];
    if (tags.includes(tagId)) {
        const indexToRemove = tags.indexOf(tagId);
        tags.splice(indexToRemove, 1);
        tags = JSON.stringify(tags);
    } else {
        tags.push(tagId);
        tags = JSON.stringify(tags);
    }
    return tags;
}

Здесь нам нужно преобразовать массив выбранных идентификаторов тегов в строку, поскольку наш вход принимает строковое значение.

Теперь давайте обсудим функционал по удалению выбранных тегов. Когда пользователь нажимает кнопку закрытия, соответствующий элемент необходимо удалить как из tags-select-list, так и из нашего поля ввода. Вот код функции удаления:

const removeSelectedTag = (el) => {
    tagSelectInput.value = inputTag(parseInt(el.id));
    tagSelectWrap.removeChild(el);
}

Удивительно, давайте посмотрим на наш фантастический результат:

Последний шаг — реализовать функционал добавления нового тега, когда пользователь нажимает Enter и введенный тег не найден в наших существующих записях. В этом случае мы отправим введенный тег на сервер и сохраним его как новый тег. В результате мы повторим весь процесс нажатия на элемент списка для вновь добавленного тега.

Чтобы добавить новый тег, вам просто нужно включить функцию сохранения внутри контроллера тегов. Вот как можно реализовать функцию магазина:

public function store(StoreTagRequest $request)
{
    $tag = Tag::create($request->validated());
    return response()->json($tag);
}

Чтобы убедиться, что все настроено правильно, обязательно добавьте соответствующий маршрут и простой класс StoreTagRequest. Мы рассмотрели этот шаг ранее, и он относительно прост.

Теперь давайте вернемся к нашему поиску, выберите ввод и обработаем события клавиши Enter. Для начала нам нужно проверить, нет ли в наших записях тегов. Если существующих тегов нет, мы приступим к сохранению нового тега, выполнив следующие шаги:

tagSearchInput.addEventListener("keydown", async function (e) {
    if (e.key === "Enter") {
        e.preventDefault();

        const response = await axios.get(
            `/tags?name=${tagSearchInput.value}`
        );

        if (!response.data.length) {
            const { data } = await axios.post("/tags", {
               name: tagSearchInput.value
            });
            tagSelected(data);
        }
    }
});

Теперь у нас есть замечательная возможность добавлять новые теги в наше приложение! Разве это не удивительно?

Что ж, вернемся к процессу создания задачи. Здесь нам просто нужно получить введенные теги из полей ввода при отправке запроса.

давайте приступим к обновлению нашего taskStoreRequest, включив в него выбранные теги:

public function rules(): array
{
    return [
        'title' => 'required|min:3|max:120',
        'description' => 'nullable|min:3|max:255',
        'expired_at' => 'nullable|date|after:now'
    ];
}


public function validated($key = null, $default = null)
{
    if ($key == 'tags') return json_decode(request('tags'));

    return $this->validator->validated();
}

Я только что провел рефакторинг метода validated для преобразования строки идентификаторов в массив идентификаторов (Мы не можем объединить, поскольку слияние может привести к конфликтам, поскольку внутри модели задач нет столбца с именем «теги») .

Теперь давайте проверим его функциональность, используя два заданных тега.

Вот и все!

Сохраните задачу с заданными тегами

Как теперь связать эти теги с нашими задачами?

Как я упоминал ранее, нам нужен способ управлять взаимосвязью между тегами и задачами. В предыдущей статье мы обсуждали эту тему, вы можете узнать здесь:



Чтобы определить связь между тегами и задачами, нам понадобится еще одна таблица, в которой будут записаны все связи между этими моделями. Эта средняя таблица будет иметь два основных атрибута: идентификатор каждой модели.

Итак, приступим и определим таблицу с помощью миграции:

php artisan make:migration create_tag_task_table

И это наша настройка миграции:

Schema::create('tag_task', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
});

Прямо сейчас нам нужно определить функцию отношения для обеих моделей, которая невероятно полезна и включает в себя множество полезных функций, которые упрощают работу с отношениями в моделях Laravel. Для обеих моделей нам нужно объявить, что каждая модель принадлежит другой.

class Task extends Model
{
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    // ...
}


class Tag extends Model
{
    public function tasks()
    {
        return $this->belongsToMany(Task::class);
    }

    // ...
}

Вернемся к нашему контроллеру, где нам нужно определить связь между созданной задачей и выбранными тегами. Здесь мы будем использовать другой вызов функции attach, как показано ниже:

public function store(StoreTaskRequest $request)
{
    $task = auth()->user()->tasks()->create($request->validated());

    $task->tags()->attach($request->validated('tags'));

    return redirect("/tasks", 201);
}

Как вы заметили, после выбора некоторых тегов и отправки запроса на сохранение на сервер вы заметите, что созданная задача связана с выбранными тегами.

Обновить теги задачи

А что насчет обновления тегов для данной задачи?

Основное требование — включить ввод выбора с использованием предоставленных тегов. К счастью, в блейдах Laravel есть полезная функция под названием «props», которая позволяет вставлять переменные внутри компонентов. В нашем проекте нам нужно определить переменную tags со значением по умолчанию, равным нулю. Это позволит нам эффективно использовать компонент:

@props([ 'tags' => null ])

Затем нам нужно добавить выбранные теги во вход tags-select:

<input 
  type="text" 
  name="tags"
  id="tags-select" 
  value="{{ isset($tags) ? $tags->pluck('id') : ''}}"
  hidden
>

И покажем выбранные теги внутри нашего tags-select-list:

<div class="flex flex-wrap space-x-1" id="tags-select-list">
    @forelse($tags ?? [] as $tag)
        <span id="{{ $tag->id }}" class="tag-select-item">
          <i>×</i>{{ $tag->name }}
        </span>
    @empty
    @endforelse
</div>

Как видите, forelse используется в тех случаях, когда список может быть пустым. Вы также можете использовать простой цикл foreach вместе с оператором if, но forelse предоставляет более простую альтернативу.

Теперь вы можете использовать этот компонент в нашем представлении редактирования. При этом вы заметите, что все выбранные теги отображаются правильно. Однако есть одна маленькая вещь, которую все еще необходимо решить: функция закрытия этих тегов.

В этой ситуации все, что нам нужно сделать, это выполнить поиск в нашем списке. Если какие-либо элементы диапазона найдены, мы можем назначить функцию обработчика закрытия кнопке закрытия. Этот процесс прост, и для его реализации вы можете обратиться к следующему коду:

// Get all the tags inside the parent element add close event
[...tagSelectWrap.getElementsByTagName('span')].forEach(spanTag => {
    spanTag.getElementsByTagName('i')[0].onclick = () => removeSelectedTag(spanTag);
})

Вот и все!

Затем я добавляю наши компоненты поиска в представление редактирования задачи и реорганизую наш метод обновления, как показано ниже:

Теперь если мы добавим дополнительную задачу и сделаем обновления, вы заметите, что не только будут добавляться новые теги, но и вновь будут прикреплены последние прикрепленные теги.

Одним из возможных решений является сначала отсоединить все ранее прикрепленные теги, а затем соединить их все вместе. Однако другой вариант — использовать вспомогательную функцию sync, которая обрабатывает весь процесс за один шаг:

// option 1:
$tags_id = $task->tags()->pluck('id');
$task->tags()->detach($tags_id);
$task->tags()->attach($request->validated('tags'));

// option 2:
$task->tags()->sync($request->validated('tags'));

Вот и все. Посмотрим на результат:

Мы только что завершили невероятную и обширную статью, охватывающую широкий спектр тем, включая увлекательную сферу отношений «многие-ко-многим». Кроме того, мы представили совершенно новую модель под названием «тег» и установили связь между задачами пользователя и соответствующими тегами. Чтобы улучшить взаимодействие с пользователем, мы даже разработали блейд-компоненты, специально предназначенные для управления тегами в наших формах.

И снова это репозиторий нашего проекта. На протяжении всей этой серии материалов мы будем предпринимать постепенные шаги, чтобы превратить его в полнофункциональное приложение.

Приятного кодирования!