• Время чтения ~6 мин
  • 16.03.2023

Несколько недель назад я построил доску Kanban с использованием Laravel и InertiaJS для клиента. Одним из требований к плате было разрешение пользователям перетаскивать карты в один и тот же столбец и через другие столбцы.

Это требование включало в себя написание эффективного исходного кода для сохранения новой позиции карты (карт) после каждого перетаскивания на доску.

Существует несколько способов сохранения этого повторного заказа. Вот несколько вариантов, с которыми я столкнулся.

  • Этот URL-адрес объясняет, как Trello вычисляет положение каждой карты после успешного перетаскивания.

  • Алгоритм переупорядочивания сжатия может помочь решить такую проблему. Вот более подробное объяснение того , как работает этот алгоритм.

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

Для этой статьи я решил использовать вариант 3. Поскольку я буду использовать InertiaJS (Vue JS) на переднем плане, пересчет новых позиций карт с помощью JavaScript - это легко.

Существует несколько способов работы с новой позицией карт на бэкэнде.

  • Одним из способов является подготовка инструкции SQL Update и использование метода DB::update() для выполнения обновления.
  • Другим способом является использование функции Eloquent upsert() для выполнения обновлений. Несмотря на то, что метод upsert() может выполнять как операции вставки, так и операции обновления, в нашем случае upsert() будет использоваться только для выполнения обновлений. Мы обновляем позиции карт, хранящиеся в базе данных.

Давайте перейдем прямо к исходному коду приложения и рассмотрим реализацию.

Необходимые условия

  • Я создал новое приложение Laravel 10 с помощью опции Docker. Однако можно выбрать любой другой метод для локального создания нового пустого приложения Laravel.

  • Я установил стартовый комплект Laravel Breeze. Это необязательно, но мне нравится, как этот пакет легко формирует множество просмотров и страниц :-) Установив Laravel Breeze, мы установим и настроим InertiaJs , а также Tailwindcss .

Макет

Давайте начнем с изучения окончательного макета этого приложения.
Kanban board in Laravel

Доска Канбан состоит из одной или нескольких колонн.
Каждый столбец состоит из одной или нескольких карточек.

Пользователь может:

  • Добавить новые карты
  • Редактировать существующие карточки Удалить существующие карточки
  • Добавить новые столбцы
  • Удалить существующие столбцы
  • Перетащить карты в том же столбце
  • Перетащить карты по нескольким столбцам.

Я построил пользовательский интерфейс таким образом, что плата не позволяет прокручивать вертикально. Если в столбце нет места для отображения всех карточек, список карт начинает прокручиваться вертикально. Таким образом, кнопка Добавить карту продолжает отображаться в нижней части столбца.

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

Я не буду тратить больше времени на объяснение пользовательского интерфейса или того, как я построил эту плату с помощью Tailwind CSS. Вы можете проверить репозиторий самостоятельно для получения более подробной информации. Ларавель Канбан.

Компоненты

Создание модульных приложений в Vue или InertiaJS - это то, к чему я стремлюсь. Поэтому я разделил плату Kanban на независимые компоненты Vue. Давайте исследуем их вместе.

Компонент Kanban

Это основной компонент приложения. Он определяет свойство, board заполненное InertiaJS. Этот компонент резервируется вместе с маршрутомboards.

Файл routes/web.php определяет все маршруты для этого приложения.

Маршрут boards определяется следующим образом:

Route::get('/boards/{board}', [BoardController::class, 'show'])->name('boards');

когда пользователь посещает /boards URL-адрес, выполняется метод, show() определенный на BoardController::class URL-адресе. Давайте откроем для show() себя метод.

public function show(Request $request, Board $board)
{
    // Case when no board is passed over
    if (!$board->exists) {
        $board = $request->user()->boards()->first();
    }
    // eager load columns with their cards
    $board?->loadMissing(['columns.cards']);

    return inertia('Kanban', [
        'board' => $board ? BoardResource::make($board) : null,
    ]);
}

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

Затем я загружаю недостающие columns и cards отношения, определенные на моделях Column иCard.

Наконец, метод отображает компонент InertiaJS по адресу /resources/js/Pages/Kanban.vue, передав ему board свойство, представляющее объект API Resource, обернутый модельюBoard.

Компонент Kanban определяет одно свойство:

const props = defineProps({
  board: Object,
});

Он определяет вычисляемое свойство в Vue, перенося столбцы, принадлежащие плате:

const columns = computed(() => props.board?.data?.columns);

Затем он зацикливается на столбцах, доступных на текущей платеColumn, и отображает компонент:

<Column
   v-for="column in columns"
   :key="column.title"
   :column="column"
   @reorder-change="onReorderChange"
   @reorder-commit="onReorderCommit"
/>

Компонент Column выдает два основных события: reorder-change и reorder-commit. Мы вернемся к обоим событиям позже, когда обсудим перетаскивание на доске.

Наконец, он выделяет столбец для отображения кнопкиAdd column. Компонент CreateColumn обрабатывает создание нового столбца на плате.

<ColumnCreate :board="board.data" />

Далее мы рассмотрим ColumnCreate компонент.

Компонент ColumnCreate

Этот компонент имеет два режима работы. Сначала он отображается как a Button с меткой Add another list. Пользователь нажимает эту кнопку, и появляется aForm, позволяющий пользователю указать a name для нового столбца.

  <div>
   <form
      v-if="isCreating"
      @keydown.esc="isCreating = false"
      @submit.prevent="onSubmit"
      class="p-3 bg-gray-200 rounded-md"
      >
      <input
         v-model="form.title"
         type="text"
         placeholder="Column name ..."
         ref="inputColumnNameRef"
         class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
         />
      <div class="mt-2 space-x-2">
         <button
            type="submit"
            class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
            >
         Add column
         </button>
         <button
            @click.prevent="isCreating = false"
            type="button"
            class="inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-gray-700 hover:text-black focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
            >
         Cancel
         </button>
      </div>
   </form>
   <button
      v-if="!isCreating"
      @click.prevent="showForm"
      type="button"
      class="flex items-center p-2 text-sm rounded-md font-medium bg-gray-200 text-gray-600 hover:text-black hover:bg-gray-300 w-full"
      >
      <PlusIcon class="w-5 h-5" />
      <span class="ml-1">Add another list</span>
   </button>
</div>

Я обрабатываю логику отображения/скрытия формы с помощью переменной isCreating Vue ref().

const showForm = async () => {
  isCreating.value = true;
  await nextTick(); // wait for form to be rendered
  inputColumnNameRef.value.focus();
};

Метод showForm() запускается, когда пользователь нажимает кнопку добавления нового столбца.

Он поворачивает isCreating значение to true, ожидает, пока Vue JS не выполнит изменения DOM с помощью метода nextTick(), и установит фокус на входных данных столбцаname.

const onSubmit = () => {
    form.post(route('boards.columns.store', {
        board: props?.board
    }), {
        onSuccess: () => {
            form.reset();
            isCreating.value = false;
        },
    });
};

При отправке формы на маршрут отправляется boards.columns.store запрос POST. Этот routes/web.php маршрут определяется следующим образом:

Route::post('/boards/{board}/columns', BoardColumnCreateController::class)
        ->name('boards.columns.store');

Это BoardColumnCreateController::class вызывающий контроллер в Laravel, который обрабатывает создание нового столбца в базе данных.

public function __invoke(StoreColumnRequest $request, Board $board): RedirectResponse
{
$board->columns()->save(Column::create($request->all()));
        return back();
}

Контроллер сначала проверяет запрос с помощью StoreColumnRequest::class запроса формы. Затем он сохраняет новый столбец в базе данных и возвращается в представление доски.

Компонент

Column столбца Компонент привязан к одному столбцу на плате. Он определяет два свойства:

  • Объект
  • столбца идентификатора

доски Он определяет события, которые он может излучать родительскому компоненту.

const emit = defineEmits(['reorder-commit', 'reorder-change']);

Кроме того, этот компонент определяет реактивное cards свойство с помощью функции Vue JSref(). Затем он использует это свойство для отображения карт внутри Draggable списка.

<Draggable
   v-model="cards"
   group="cards"
   item-key="id"
   tag="ul"
   drag-class="drag"
   ghost-class="ghost"
   class="space-y-3"
   @change="onReorderCards"
   @end="onReorderEnds"
   >
   <template #item="{ element }">
      <li>
         <Card :card="element" />
      </li>
   </template>
</Draggable>

Я использовал знаменитую библиотеку пакетов Vue.Draggable Vue3.

КомпонентDraggable:

  • Renders as a
  • ul Bound to the cards property
  • Fires два события: change и end
  • Renders каждая карта внутри <li> элемента с помощью компонента

Card Мы вернемся позже, чтобы дополнительно обсудить обработчики событий перетаскивания.

Чтобы удалить столбец, нажмите кнопку ... настроек в правом верхнем углу каждого столбца. Я использовал раскрывающийся компонент, предлагаемый Menu библиотекой пакетов @headless/vue, для создания раскрывающегося меню с нужными параметрами. Я добавил опцию Delete a column в этом случае.

! [Исключить колонку] (https://user-images.githubusercontent.com/1163421/222558878-916be407-a2c4-4ca3-8f9c-d4d6c9aa16f8.png "Исключить колонку")

Перед удалением столбца необходимо сначала проверить эту операцию. Для этого я использовал компонент, предлагаемый Modal библиотекой пакетов @headless/vue.

! [Подтверждение удаления столбца] (https://user-images.githubusercontent.com/1163421/222558897-46a9f689-7fd3-4877-a317-9edef07d016d.png "Подтверждение удаления столбца")

При подтверждении удаления эта строка кода отвечает за отправку запроса DELETE в серверную часть Laravel для выполнения удаления столбца.

outer.delete(route('columns.destroy', { column: props?.column?.id }));

Маршрут columns.destroy сопоставляется с ColumnDestroyController::class внутренней частью routes/web.php файла.

Route::delete('/columns/{column}', ColumnDestroyController::class)->name('columns.destroy');

Контроллер ColumnDestroyController::class in invokable, который удаляет соответствующий столбец и перезагружает страницу.

public function __invoke(Column $column): RedirectResponse
{
    $column->delete();
    return back();
}

Компонент Column также ссылается на CardCreate компонент, который обрабатывает добавление новой карты в столбец. Мы рассмотрим это в ближайшее время.

Компонент

CardCreate Этот компонент имеет два режима работы. Во-первых, он отображается Button как помеченный Add card. Пользователь щелкает по ней, и появляется aForm, позволяющий content пользователю указать для новой карты.

Я использовал ту же логику, что и для создания нового столбца.

Компонент

карты Компонент Card является простым. Он имеет два режима работы. Один для редактирования содержимого, а другой для отображения содержимого карточки внутри столбца.

! [Пользовательский интерфейс карты] (https://user-images.githubusercontent.com/1163421/222906920-a07b8968-7ef2-4875-9de5-ac0022299ee2.png "Кардуи")

При наведении курсора на карточку появляются два значка. Один для редактирования содержимого карточки, а другой для ее удаления.

  • Редактирование карточки происходит на месте с помощью Form элемента.
  • При щелчке значка корзины открывается модальное диалоговое окно с запросом на подтверждение удаления.

Использует ConfirmDialog компонент, который я собрал, обернув a DialogPanel из библиотеки пакетов @headlessui/vue.

Dialog Компонент Dialog использует слоты Vue JS для заголовка, тела и действий диалогового окна.

Если вы хотите расширить эту плату Kanban с помощью другого типа Modal, вы можете Dialog использовать компонент и настроить пользовательский интерфейс так, как вы хотите.

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

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

Однако в этом случае использование a Store является излишним. Мы сохраним ту же концепцию, за исключением использования Vue JS ref() для создания компонуемого хранилища, которое предлагает общее хранилище данных для хранения текущего идентификатор редактируемого.

Создайте новую папку по адресу resource/js/Composables/. Добавьте новый компонуемый файл useEditCard.js JavaScript со следующим содержимым.

import { ref } from 'vue';

export const useEditCard = ref({
  currentCard: null,
});

Компонуемый экспортирует ссылочную useEditCard оболочку объекта с одним свойством currentCard.

Теперь давайте вернемся к компоненту Card и воспользуемся этой компонуемой.

Во-первых, начните с импорта компонуемого.

import { useEditCard } from '@/Composables/useEditCard';

Внутри обработчика событий для отображения формы редактирования задайте значение currentCard на компонуемой.

const showForm = async () => {

  useEditCard.value.currentCard = props?.card?.id;

  // …
};

Компонент Card уже принимает card объект. Мы размещаем id карту, нажатую useEditCard.value.currentCard внутри объекта.

Этот метод гарантирует, что всегда есть одна карта в режиме редактирования.

Также важно обработать обработчик событий отмены формы редактирования. Вы должны сбросить значение, currentCard чтобы отразить это.

const onCancel = () => {
  useEditCard.value.currentCard = null;
};

Вот и все для Card компонента. Давайте перейдем к заключительному отрезку и обсудим управление перетаскиванием на бэкэнде.

Перетаскивание картОчек

In the section under Компонент, I’ve shown the markup required to allow dragging and dropping cards within the same column or across other columns.

<Draggable
   v-model="cards"
   group="cards"
   item-key="id"
   tag="ul"
   drag-class="drag"
   ghost-class="ghost"
   class="space-y-3"
   @change="onReorderCards"
   @end="onReorderEnds"
   >
   <template #item="{ element }">
      <li>
         <Card :card="element" />
      </li>
   </template>
</Draggable>

Реализация перетаскивания в Vue 3 еще никогда не была такой простой с помощью библиотеки пакетов Vue Draggable.

Я привязываю Draggable компонент к переменной cards . Давайте проверим, где определена эта переменная.

const cards = ref(props?.column?.cards);

Это ref() обертывание массива карт на входном свойстве column.

Мы также подключаемся к двум событиям, испускаемым Draggable компонентом: change и end.

Давайте рассмотрим onReorderCards() обработчик событий.

const onReorderCards = () => {
    const cloned = cloneDeep(cards?.value);

    const cardsWithOrder = [
        ...cloned?.map((card, index) => ({
            id: card.id,
            position: index * 1000 + 1000,
        })),
    ];
    emit('reorder-change', {
        id: props?.column?.id,
        cards: cardsWithOrder,
    });
};

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

Код выполняется через клонированную версию cards. Он сопоставляет каждую карту в объект с двумя свойствами: id и position.

Помните, cards что всегда держите последнюю re-ordered версию карточек столбцов. Таким образом, приведенный выше код сбрасывает position свойство для всех карт и присваивает им последовательные позиции, представляющие фактический порядок этих карт в столбце.

Затем метод выдает событие reorder-change , передающее в качестве полезной нагрузки события объект, содержащий столбца карты и вновь позиционированные карты.

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

Давайте обсудим onReorderEnds() обработчик событий.

const onReorderEnds = () => {
  emit('reorder-commit');
};

Обработчик событий просто выдает другое событие, reorder-commit событие. Компонент Draggable инициирует событие, end когда операция перетаскивания заканчивается. Следовательно, мы могли бы использовать этот обработчик событий для сохранения изменений в базе данных. Давайте посмотрим, как мы это делаем.

Теперь мы хотим проверить, как мы передаем оба события и reorder-change reorder-commit.

Возвращаясь к компоненту Kanban , мы увидели, как мы визуализируем столбцы.

<Column
   v-for="column in columns"
   :key="column.title"
   :column="column"
   @reorder-change="onReorderChange"
   @reorder-commit="onReorderCommit"
/>

Обратите внимание, как мы подключаемся к обоим событиям. Рассмотрим оба обработчика событий по порядку.

Обработчик onReorderchange() событий определяется следующим образом:

const onReorderChange = column => {
  columnsWithOrder.value?.push(column);
};

это columnsWithOrder Vue JS ref() , который заключает массив в оболочку. Этот метод отправляет полезную нагрузку события в эту реактивную переменную.

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

С другой стороны, onReorderCommit() обработчик событий определяется следующим образом:

const onReorderCommit = () => {
  if (!columnsWithOrder?.value?.length) {
    return;
  }
  router.put(route('cards.reorder'), {
    columns: columnsWithOrder.value,
  });
};

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

Давайте перейдем к серверному коду и рассмотрим код, который фактически выполняет обновление базы данных.

Файл routes/web.php определяет cards.reorder маршрут следующим образом:

Route::put('/cards/reorder', CardsReorderUpdateController::class)->name('cards.reorder');

Дескрипторы CardsReorderUpdateController::class обновляют карты в базе данных.

public function __invoke(CardsReorderUpdateRequest $request): RedirectResponse
{
    $data = collect($request->columns)
                ->recursive() // make all nested arrays a collection
                ->map(function ($column) {
                    return $column['cards']->map(function ($card) use ($column) {
                        return ['id' => $card['id'], 'position' => $card['position'], 'column_id' => $column['id']];
                 });
            })
            ->flatten(1)
            ->toArray();

    // Batch
    Card::query()->upsert($data, ['id'], ['position', 'column_id']);

    return back();
}

Давайте шаг за шагом рассмотрим этот код.

Прежде всего, мы используем CardsReorderUpdateRequest::class [Запрос формы] (https://laravel.com/docs/10.x/validation#form-request-validation).

Рассмотрим функцию rules() объекта запроса формы.

public function rules(): array
{
    return [
        'columns.*.id' => ['integer', 'required', 'exists:\App\Models\Column,id'],
        'columns.*.cards' => ['required', 'array'],
        'columns.*.cards.*.id' => ['required', 'integer', 'exists:\App\Models\Card,id'],
        'columns.*.cards.*.position' => ['numeric', 'required'],
    ];
}

Laravel позволяет проверять вложенные массивы с помощью API проверки.

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

Вернемся к контроллеру.

Мы начнем с упаковки полезных данных массива в коллекцию Laravel.

$data = collect($request->columns)

Сразу после этого я преобразую все массивы PHP в коллекции Laravel на всех уровнях входящей полезной нагрузки, используя пользовательский макрос Laravel с именем recursive , который я определил внутри AppServiceProvider.

$data = collect($request->columns)->recursive()

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

Продолжим!

Затем я преобразую входящие записи в коллекцию объектов. Каждый объект имеет следующие свойства:

  • идентификатор
  • карты расположение
  • столбца
->map(function ($column) {
    return $column['cards']->map(function ($card) use ($column) {
            return ['id' => $card['id'], 'position' => $card['position'], 'column_id' => $column['id']];
        }
    );
})

Наконец, я сглаживаю коллекцию, чтобы получить конечные узлы и преобразовать в Массив.

At the end, we will have an array of objects. Each object specifies the идентификатор, the new card position, and the new столбца in case the card was dragged/dropped to another column.

Наконец, я использую метод Laravel Eloquent upsert() для выполнения операции пакетного обновления.

Метод upserts() может выполнять вставки и обновления одновременно. Однако в нашем случае, поскольку у нас есть проверка, чтобы убедиться, что столбцы и карты действительно существуют в базе данных, она всегда будет выполнять операцию обновления. Мы хотим обновить новые позиции и столбцы карты.

По определению, upsert() метод принимает три параметра:

  • $values параметр содержит данные, подлежащие обновлению/вставке в базу данных
  • $uniqueBy параметр, чтобы попытаться сначала извлечь записи (если он существует),
  • $update параметр представляет собой массив столбцов базы данных для обновления
Card::query()->upsert($data, ['id'], ['position', 'column_id']);

В нашем случае $values параметр содержит массив объектов карт с их новой позицией и новым столбцом (если изменен).

The $unique parameter is the card id. Therefore, the upsert() checks if the record exists in the cards database table based on the идентификатор.

Наконец, $update параметр содержит массив столбцов для обновления в базе данных. В нашем случае мы хотим обновить как столбцы в position таблице базы данных, так и column_id столбцы cards .

Приведенная выше инструкция обновляет все карты в полезных данных с помощью одной инструкции базы данных.

insert into `cards` (`column_id`, `created_at`, `id`, `position`, `updated_at`) values (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?) on duplicate key update `position` = values(`position`), `column_id` = values(`column_id`), `updated_at` = values(`updated_at`)",[/* … */]

This way, no matter how many cards are involved, there will be a single database operation to update the new position and столбцаs.

Ссылки

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

Вывод

я оставил многие детали, связанные с Vue JS, без объяснений. Причина этого заключается в том, что я хотел сосредоточиться на функции перетаскивания карт и на том, как мы эффективно обновляем записи в базе данных.

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

Вы можете вытащить репозиторий на GitHub Laravel Kanban и попробовать его самостоятельно локально.

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