使用 Laravel 和 Vue.Draggable 构建看板
发布于 作者: Bilal Haidar
几周前,我为客户使用 Laravel 和 InertiaJS 构建了一个看板。看板的要求之一是允许用户在同一列内和跨列之间拖放卡片。
这个需求涉及编写高效的源代码,以在每次在看板上拖放后保存新的卡片位置。
处理保存此重新排序有多种方法。以下是我遇到的几种选择。
-
挤压重新排序算法可以帮助解决此类问题。这里有 更多解释 关于此算法的工作原理。
-
在每次拖放操作中,重新排序受影响的卡片,将其新位置值发送到后端,并批量保存更改。
在这篇文章中,我决定使用选项 3。由于我将在前端使用 InertiaJS(Vue JS),因此使用 JavaScript 重新计算卡片的新位置轻而易举。
在后端处理卡片的新位置有多种方法。
- 一种方法是准备一个 SQL 更新语句并使用
DB::update()
方法执行更新。 - 另一种方法是使用 Eloquent 的
upsert()
函数执行更新。虽然 upsert() 方法可以执行插入和更新操作,但在我们的例子中,upsert() 仅用于执行更新。我们正在更新存储在数据库中的卡片位置。
让我们直接进入应用程序源代码,探索实现。
先决条件
-
我使用 Docker 选项 创建了一个新的 Laravel 10 应用程序。但是,您可以选择任何其他方法在本地创建新的空白 Laravel 应用程序。
-
我安装了 Laravel Breeze 启动套件。这是可选的,但我喜欢这个包如何轻松地搭建许多视图和页面 :-) 通过安装 Laravel Breeze,我们得到了已安装和配置的 InertiaJs 以及 Tailwindcss。
布局
让我们从探索此应用程序的最终布局开始。
看板由一列或多列组成。每列包含一张或多张卡片。
用户可以
- 添加新卡片
- 编辑现有卡片
- 删除现有卡片
- 添加新列
- 删除现有列
- 在同一列内拖放卡片
- 在多个列之间拖放卡片。
我以这样一种方式构建了 UI,即看板不允许垂直滚动。当没有空间显示列中的所有卡片时,卡片列表开始垂直滚动。这样,添加卡片按钮会一直显示在列的底部。
当向看板添加许多列并且没有空间显示所有列时,看板允许水平滚动。这也是在移动设备上查看此看板时的典型行为。一次显示一列。然后,您必须向右滚动以查看其余的列。
我不会花更多时间解释 UI 或我如何使用 Tailwind CSS 构建此看板。您可以自己查看存储库以了解更多详细信息。 Laravel 看板.
组件
在 Vue 或 InertiaJS 中构建模块化应用程序是我努力实现的目标。因此,我将看板划分为独立的 Vue 组件。让我们一起探索它们。
看板组件
这是应用程序的主要组件。它定义了由 InertiaJS 填充的 board
属性。此组件由 boards
路由支持。
routes/web.php
文件定义了此应用程序的所有路由。
boards
路由定义如下
Route::get('/boards/{board}', [BoardController::class, 'show'])->name('boards');
当用户访问 /boards
URL 时,BoardController::class
上定义的 show()
方法将被执行。让我们发现 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
模型实例作为输入。但是,可以打开此路由而不传递任何看板。在这种情况下,我决定加载数据库中的第一个看板。
然后,我加载了在 Column
和 Card
模型上定义的缺少的 columns
和 cards
关系。
最后,该方法在 /resources/js/Pages/Kanban.vue
处呈现 InertiaJS 组件,并向其传递 board
属性,该属性表示一个 API 资源对象,它包装在 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
。当我们讨论看板上的拖放时,我们将回到这两个事件。
最后,它专设一列来显示 添加列
按钮。CreateColumn
组件处理在看板上创建新列。
<ColumnCreate :board="board.data" />
接下来,我们将查看 ColumnCreate
组件。
ColumnCreate 组件
此组件有两种操作模式。首先,它以 Button
的形式呈现,标签为 添加另一个列表。
用户点击此按钮,将出现一个 Form
,允许用户为新列指定一个 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
设为 true
,等待 Vue JS 使用 nextTick() 方法执行 DOM 更改,并将焦点设置在列 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 组件
Column
组件绑定到看板中的单个列。它定义了两个属性
- 看板 ID
- 列对象
它定义了它可以向父组件发出的事件。
const emit = defineEmits(['reorder-commit', 'reorder-change']);
此外,此组件使用 Vue JS 的 ref()
函数定义了一个响应式 cards
属性。然后,它使用此属性在 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
组件
- 以
ul
的形式呈现 - 绑定到
cards
属性 - 触发两个事件:
change
和end
- 使用 Card 组件在
<li
> 元素中呈现每个卡片
我们稍后将返回进一步讨论拖放事件处理程序。
要删除列,请点击每列右上角的...
设置按钮。我使用了由@headless/vue包库提供的Menu
下拉菜单组件来构建一个下拉菜单,其中包含我想要的选项。在本例中,我添加了删除列
选项。
![删除列](https://user-images.githubusercontent.com/1163421/222558878-916be407-a2c4-4ca3-8f9c-d4d6c9aa16f8.png “删除列”)
在删除列之前,您需要先验证此操作。为此,我使用了由@headless/vue包库提供的Modal
组件。
![列删除确认] (https://user-images.githubusercontent.com/1163421/222558897-46a9f689-7fd3-4877-a317-9edef07d016d.png “列删除确认”)
确认删除后,这行代码负责向 Laravel 后端发送 DELETE 请求以执行列删除操作。
router.delete(route('columns.destroy', { column: props?.column?.id }));
路由columns.destroy
映射到routes/web.php
文件中的ColumnDestroyController::class
。
Route::delete('/columns/{column}', ColumnDestroyController::class)->name('columns.destroy');
ColumnDestroyController::class
是一个可调用控制器,它会删除相应的列并重新加载页面。
public function __invoke(Column $column): RedirectResponse{ $column->delete(); return back();}
Column
组件还引用了CardCreate
组件,该组件处理将新卡片添加到列中。我们很快就会看到这一点。
CardCreate 组件
此组件有两种操作模式。首先,它呈现为一个标记为添加卡片
的Button
。用户点击它,就会出现一个Form
,允许用户指定新卡片的内容
。
我使用了与创建新列相同的逻辑。
Card 组件
Card
组件是一个简单的组件。它有两种操作模式。一种是编辑内容,另一种是在列中显示卡片的内容。
![Card UI](https://user-images.githubusercontent.com/1163421/222906920-a07b8968-7ef2-4875-9de5-ac0022299ee2.png “CardUI”)
将鼠标悬停在卡片上时,会出现两个图标。一个是编辑卡片内容,另一个是删除卡片。
- 使用
Form
元素在原位编辑卡片。 - 点击垃圾桶图标会打开一个 Modal 对话框,询问您是否确认删除。
ConfirmDialog
使用我构建的Dialog
组件,包装了来自@headlessui/vue包库的DialogPanel
。
Dialog 组件使用 Vue JS 的插槽来处理对话框的标题、主体和操作。
如果您想用其他 Modal 类型扩展这个看板,您可以使用Dialog
组件并根据需要自定义 UI。
我想提醒您注意我使用的一种技巧,它可以确保一次只有一个卡片处于编辑模式。这意味着现在如果您点击编辑图标,该列中列出的所有卡片都将处于编辑模式。这是我们想要避免的。怎么做呢?
在您的项目中,如果您使用的是Pinia或Vuex,您需要在用户点击编辑卡片时将卡片 ID
存储在仓库中。
然而,在这种情况下,使用仓库
有点 overkill。我们将保持相同的概念,但使用 Vue JS 的ref()
创建一个可组合函数,该函数提供一个共享的数据存储区来存储当前正在编辑的卡片 ID
。
在resource/js/Composables/
中创建一个新文件夹。添加一个新的可组合 JavaScript 文件useEditCard.js
,其内容如下。
import { ref } from 'vue'; export const useEditCard = ref({ currentCard: null,});
该可组合函数导出一个useEditCard
ref,该 ref 包装一个包含单个属性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
组件的全部内容。让我们进入最后阶段,讨论如何处理后端的拖放操作。
拖放卡片
在Column 组件下的部分中,我展示了允许在同一列或跨不同列之间拖放卡片所需的标记。
<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包库,在 Vue 3 中实现拖放比以往更容易。
我将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
始终保存列卡片的最新重新排序
版本。因此,上面的代码所做的就是重置所有卡片的position
属性,并为它们分配顺序位置,表示这些卡片在列中的实际顺序。
然后,该方法发出一个名为reorder-change
的事件,并传递一个对象作为事件负载,该对象包含列 ID
和新定位的卡片。
此方法对参与拖放操作的任何列都运行一次。例如,如果您在同一列内拖放,则此处理程序运行一次。但想象一下将卡片从一个列拖放到另一个列。那么这个处理程序将对每个列运行一次。
让我们讨论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, });};
此事件处理程序向cards.reorder
路由发送 POST 请求。负载是一个数组,包含所有受拖放操作影响的列,包括每列的卡片,以及卡片在列中的新位置。
让我们切换到服务器端代码,并浏览实际执行数据库更新的代码。
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.net.cn/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
和卡片 ID
都存在于数据库中。您可以跳过此验证,但这可以保证没有人可以破坏您的看板。请记住,对于请求负载中的每个列和卡片,都会有一个数据库请求来验证相应的ID
是否存在于数据库中。
让我们回到控制器。
我们首先将数组负载包装到一个 Laravel 集合中。
$data = collect($request->columns)
紧随其后,我使用一个自定义的Laravel 宏名为recursive
,在AppServiceProvider
中定义,将传入负载中所有级别的所有 PHP 数组转换为 Laravel 集合。
$data = collect($request->columns)->recursive()
对我来说,处理集合比处理数组更容易。这样,我就可以以更一致、更高效的方式管道函数。
让我们继续!
接下来,我将传入的记录转换为一个对象集合。每个对象都具有以下属性
- 卡片 ID
- 卡片位置
- 列 ID
->map(function ($column) { return $column['cards']->map(function ($card) use ($column) { return ['id' => $card['id'], 'position' => $card['position'], 'column_id' => $column['id']]; } );})
最后,我展平了集合以获取叶子节点并转换为数组。
最后,我们将得到一个对象数组。每个对象都指定了卡片 ID
、新卡片的位置
,以及在卡片被拖放到另一个列的情况下新的列 ID
。
最后,我使用 Laravel Eloquent 的upsert()方法执行批处理更新操作。
upserts()
方法可以同时执行插入和更新。但是,在我们的案例中,由于我们已经进行了验证以确保列和卡片确实存在于数据库中,因此它将始终执行更新操作。我们希望更新卡片的新位置和列。
根据定义,upsert()
方法接受三个参数
- $values 参数包含要更新/插入数据库的数据
- $uniqueBy 参数用于首先尝试检索记录(如果存在)
- $update 参数是一个要更新的数据库列数组
Card::query()->upsert($data, ['id'], ['position', 'column_id']);
在我们的案例中,$values
参数包含带有其新位置和新列(如果已更改)的卡片对象数组。
$unique
参数是卡片的ID
。因此,upsert()
会根据卡片 ID
检查记录是否在cards
数据库表中存在。
最后,$update
参数包含要更新的数据库列数组。在我们的案例中,我们希望更新cards
数据库表中的position
和column_id
列。
上面的语句使用单个数据库语句更新负载中的所有卡片。
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`)",[/* … */]
这样,无论涉及多少卡片,都只会执行一次数据库操作来更新新位置和列 ID。
参考
我想要感谢我在撰写这篇文章并构建其源代码时使用的一些资源。
结论
我省略了很多与 Vue JS 相关的细节,没有进行解释。原因是,我想要专注于拖放卡片的功能,以及我们如何有效地将记录更新回数据库。
如果您需要询问有关源代码的信息,请随时通过电子邮件向我发送您的问题和疑问。
您可以在 GitHub 上拉取仓库 Laravel Kanban 并自己尝试在本地运行。
您好 👋!我是 Bilal,Let's Remote 的创始人兼 CEO。我是一名经验丰富的网页开发者,拥有 16 年以上的经验。Laravel 是我的强项。