The Problem
I have multiple places where I wanted to provide a tree view:
- The taxon navigator used in the navigation bar
- The root taxon picker used in
- The filter of the index page for taxa
- The filter of the index page for observations
- The edit page for checklists
- The exclude taxa picker used in the edit/create pages for checklists
- The checklist category picker used in the edit/create pages for checklists
- The article category picker used in the edit/create pages for articles
- The index page for checklist categories
- The index page for article categories
I was very keen to have a single, generic component for the tree view.
I also display different types of output for the different places. For the navigator, each item is a link to a taxon page. For the 'pickers' its a clickable item that must return its id. For the category index pages, each item is a component that allows editing, deleting and moving categories up and down within their parent.
The Tree View Requirements
The easiest way to describe the basics of what I wanted to achieve is with an image. This is a screenshot of the taxon navigator.
The aim was to make it expandable and collapsible (as any tree view) but also (and this is very important given the number of taxon records I have) to lazy load the child items only when the parent item is expanded.
The less basic requirements (mostly related to making it generic) were to allow:
- the component rendered for each item to be configurable
- the
Model
class used to fetch records to be configurable - a choice between single-select and multi-select
- pre-including selected items, with all their ancestors included and pre-expanded, and the selected items highlighted.
Making it Generic
A key point, which may seem obvious, is that all the Model
classes for which a tree view is required are based on hierarchical tables. The first step in making the tree view generic was to standardize the Model
classes for these tables.
I created a HierarchicalModel
abstract class that extends the Model
class. Each of the hierarchical table model classes extend HierarchicalModel
. I had already standardized on the column name for hierarchical relationships: parent_id
.
abstract class HierarchicalModel extends Model
{
protected static string $sortChildrenBy;
/**
* Get the parent model of the model.
*/
public function parent()
{
return $this->belongsTo(static::class, 'parent_id');
}
/**
* Get the child models of the model.
*/
public function children()
{
$relations = $this->hasMany(static::class, 'parent_id');
$relations->orderBy('set_start', 'ASC');
return $relations;
}
/**
* Determine if a model has any child model.
*/
public function hasChild()
{
return $this->set_end - $this->set_start > 1;
}
/**
* Get the ids of all descedants of the current model
*/
public function getDescendantsAttribute()
{
return static::where('set_start', '>', $this->set_start)
->where('set_end', '<', $this->set_end)
->orderBy('set_start', 'asc')
->get();
}
/**
* Get the ancestry of the current model
*/
public function getAncestorsBottomUpAttribute()
{
return static::where('set_start', '<', $this->set_start)
->where('set_end', '>', $this->set_end)
->orderBy('set_start', 'desc')
->get();
}
/**
* Get the ancestry of the current model
*/
public function getAncestorsAttribute()
{
return static::where('set_start', '<', $this->set_start)
->where('set_end', '>', $this->set_end)
->orderBy('set_start', 'asc')
->get();
}
/**
* Get the siblings of the current model
*/
public function getSiblingsAttribute()
{
if (empty($this->parent_id)) {
return static::whereNull('parent_id')->get();
}
return $this->parent->children;
}
/**
* Deals with hierarchical model and nested set specific functionality
* Then calls the parent save method.
*
* @return bool
*/
public function save(array $options = []): bool
{
try {
/* If necessary, the nested set start and end values */
if ($this->isDirty(['parent_id', static::$sortChildrenBy])) {
$sortChildrenBy = static::$sortChildrenBy;
/* Find the next sibling of the new position of this node */
if (empty($this->parent_id)) {
$baseQuery = static::whereNull('parent_id');
$this->depth = 1;
} else {
$baseQuery = static::where('parent_id', $this->parent_id);
$this->depth = $this->parent->depth;
}
$nextSibling = $baseQuery->where($sortChildrenBy, '>', $this->$sortChildrenBy)->first();
/*
* If updating, move the node (and any descendants).
* If adding, make space in the set for the new node
*/
if ($this->exists) {
/* Determine the new position of the moved node(s) */
if (empty($this->parent_id) && empty($nextSibling)) {
/* Move to be the last top level node */
$newEnd = static::max('set_end');
} else {
if (empty($nextSibling)) {
/* If we don't have a next sibling, we add as the last child of the parent */
$movingUp = $this->parent->set_end > $this->set_end;
if ($movingUp) {
$newEnd = $this->parent->set_end - 1;
} else {
$newStart = $this->parent->set_end;
}
} else {
$movingUp = $nextSibling->set_end > $this->set_end;
if ($movingUp) {
$newEnd = $nextSibling->set_start - 1;
} else {
$newStart = $nextSibling->set_start;
}
}
}
$width = $this->set_end - $this->set_start + 1;
if (isset($newEnd)) {
$newStart = $newEnd - $width + 1;
} else {
$newEnd = $newStart + $width - 1;
}
if ($newStart !== $this->set_start) {
/* Move the current node and descendants right out of the way. */
static::whereBetween('set_start', [$this->set_start, $this->set_end])
->update([
'set_start' => DB::raw('set_start + 1000000'),
'set_end' => DB::raw('set_end + 1000000')
]);
/* Adjust other nodes to make space for the moved subset */
$distance = $newStart - $this->set_start;
/**
* If distance is positive, we are moving up the set
* If we are moving up, we need to adjust all values between the old start and the new end.
* If we are moving down, we need to adjust all values between the new start and the old end.
*/
if ($distance > 0) {
$rangeStart = $this->set_start;
$rangeEnd = $newEnd;
$adjustment = $width;
} else {
$rangeStart = $newStart;
$rangeEnd = $this->set_end;
$adjustment = $width * -1;
}
/* We substract the adjustment. If it is negative, it will increase the values of the other nodes */
static::whereBetween ('set_start', [$rangeStart, $rangeEnd])
->update(['set_start' => DB::raw("set_start - $adjustment")]);
static::whereBetween ('set_end', [$rangeStart, $rangeEnd])
->update(['set_end' => DB::raw("set_end - $adjustment")]);
/* Move the current node and descendants to its new location */
static::where('set_start', '>', 1000000)
->update([
'set_start' => DB::raw("set_start - 1000000 + {$distance}"),
'set_end' => DB::raw("set_end - 1000000 + {$distance}")
]);
}
} else {
/* Inserting */
if (empty($this->parent_id) && empty($nextSibling)) {
/* Move to be the last top level node */
$newStart = static::max('set_end') + 1;
} else {
if (empty($nextSibling)) {
$newStart = $this->parent->set_end;
} else {
$newStart = $nextSibling->set_start;
}
}
$newEnd = $newStart + 1;
/* Make room for the new node */
static::where('set_start', '>=', $newStart)
->update(['set_start' => DB::raw("set_start + 2")]);
static::where('set_end', '>=', $newStart)
->update(['set_end' => DB::raw("set_end + 2")]);
}
$this->set_start = $newStart;
$this->set_end = $newEnd;
}
return parent::save($options);
} catch ( \Throwable $ex ) {
Log::error($ex->getMessage());
return false;
}
}
public function delete() {
$width = $this->set_end - $this->set_start + 1;
/* Substract the width from all higher node values */
static::where('set_start', '>', $this->set_start)
->update(['set_start' => DB::raw("set_start - $width")]);
static::where('set_end', '>', $this->set_end)
->update(['set_end' => DB::raw("set_end - $width")]);
parent::delete();
}
}
The parent()
method is a standard method for implementing the Eloquent belongsTo
relationship. The children()
method implements the hasMany
relationship. The purpose of the hasChild()
method is self-explanatory. The save()
and delete()
methods deal with maintaining nested set values. I will not cover those here because they are explained in detail in another article. The other methods all add attributes to the model allowing them to be easily accessed. For example, the getSiblingsAttribute()
, which returns a collection of the all models with the same parent as the current model, can be accessed with $taxon->siblings
(assuming $taxon
is an App\Models\Taxon
object). This is a really nice feature of Eloquent! The getDescendantsAttribute()
method gets all models under the current model (i.e. in its set). The getAncestorsAttribute
method get all models in the path above the current model in the hierarchy. The details of how the hasChild()
, getDescendantsAttribute()
and getAncestorsAttribute
work, are explained in the nested sets article.
TreeView
component
The The TreeView
component is a Blade component, not a Livewire component. This might seem surprising but it doesn't actually need to be reactive. It has the top-level items, which will always be fixed for any given TreeView
instance.
class TreeView extends Component
{
public string $modelName;
public string $modelComponent;
public string $target;
public bool $multiSelect;
public string $selectedItems;
public int $root;
private array $selectedItemsArray;
public function __construct(string $modelName, string $modelComponent, string $target, bool $multiSelect = false, string $selectedItems = '', int $root = 0)
{
$this->modelName = $modelName;
$this->modelComponent = $modelComponent;
$this->target = $target;
$this->multiSelect = $multiSelect;
$this->selectedItems = $selectedItems;
$this->selectedItemsArray = empty($selectedItems) ? [] : explode(',', $selectedItems);
$this->root = $root;
}
public function render()
{
$modelClass = 'App\\Models\\' . $this->modelName;
if ( empty($this->root) ) {
$items = $modelClass::whereNull('parent_id')->get();
} else {
$items = $modelClass::where('parent_id', $this->root)->get();
}
if ( !empty($this->selectedItems) ) {
$dependencies = [];
foreach ($this->selectedItemsArray as $modelId) {
$ancestorIds = array_values($modelClass::find($modelId)->ancestors->pluck('id')->toArray());
$dependencies = array_merge($dependencies, $ancestorIds);
}
foreach ($items as $idx => $item) {
$item->level = 1;
$item->index = $idx;
$this->getDependants($dependencies, $item, 2);
}
}
return view('components.tree-view', ['items' => $items]);
}
private function getDependants($dependancies, $item, $level) {
if ( in_array($item->id, $this->selectedItemsArray) ) {
// We have got down to one of the selected items so do not need to go further
$item->selected = true;
return;
}
if ( in_array( $item->id, $dependancies) ) {
$item->expanded = true;
$item->childItems = $item->children;
foreach ( $item->childItems as $child ) {
$child->level = $level;
$this->getDependants($dependancies, $child, $level + 1);
}
}
}
}
The modelName
property is the unqualified name of the appropriate Eloquent model, which is used to retrieve the models on which the TreeView
content is based. The modelComponent
properties determines how each item in the tree view will be rendered (I will explain more after I show you the Blade). The target
property is used to distinguish different TreeView
components and events dispatched from them.
If I didn't have the requirement to pre-populate and expand the tree to allow already selected items to be shown, the render()
method would be very short indeed. Basically, all it does is determine which items to display as the top items in the TreeView
. If no value is passed for the root
property, the top items are literally the top items in the table that the Model
represents (i.e. those with null
parent_id
). If a root
is passed, the top items are those that have the root
as their parent. The rest of the code is only relevant when the selectedItems
property has one or more IDs in it. It builds a full list of dependencies, which is basically all of the ancestor IDs for all of the selected items. Then, for each top-level item, it goes through the list of dependencies adding the appropriate descendant items upto and including a selected item.
The view is like this.
<div class="gnt-tree-view-body mx-auto max-h-120 overflow-y-auto relative">
<div
x-data="TreeViewApp"
x-init="target='{{ $target }}'; multiSelect='{{ $multiSelect }}';"
x-cloak
x-on:item-clicked="recordSelection($event.detail.item)"
>
<input x-ref="selectedItems" type="hidden" x-model="selectedItems" />
@foreach ($items as $idx => $item)
@php
$level = empty($item->level) ? 1 : (int) $item->level;
@endphp
<livewire:tree-view-item
:model-component="$modelComponent"
:item=$item
:index=$idx
:level=$level
:target=$target
:wire:key="$target . '-t-v-i-' . $item->id"
/>
@endforeach
</div>
</div>
@once
@push('child-scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('TreeViewApp', () => ({
selectedItems: '{{ $selectedItems ?? ''}}',
target: '',
multiSelect: false,
init() {
if ( typeof this.showNavigator !== 'undefined' ) {
this.$watch('showNavigator', value => this.scrollToSelectedItem(value));
}
},
recordSelection(itemId) {
// Convert itemId to a string for easier handling
itemId = itemId.toString();
let selectedEvent = new CustomEvent('item-selected', { bubbles: true, detail: {item: itemId, target: this.target} });
let selectionChanged = false;
if ( this.multiSelect ) {
// With multi-select, the clicked item is added, if not already selected, otherwise removed
if (this.selectedItems) {
let arr = this.selectedItems.split(',');
let idx = arr.indexOf(itemId);
if ( idx === -1 ) {
this.selectedItems += ',' + itemId;
selectionChanged = true;
document.dispatchEvent(selectedEvent);
} else {
arr.splice(idx, 1);
this.selectedItems = arr.toString();
selectionChanged = true;
let deselectedEvent = new CustomEvent('item-deselected', { bubbles: true, detail: {item: itemId, target: this.target} });
document.dispatchEvent(deselectedEvent);
}
} else {
this.selectedItems = itemId;
selectionChanged = true;
document.dispatchEvent(selectedEvent);
}
} else {
if (this.selectedItems != itemId) {
let deselectedEvent = new CustomEvent('item-deselected', { bubbles: true, detail: {item: this.selectedItems, target: this.target} });
document.dispatchEvent(deselectedEvent);
this.selectedItems = itemId;
selectionChanged = true;
document.dispatchEvent(selectedEvent);
}
}
if ( selectionChanged ) {
// Dispatch a target-specific event that can be listened to my consumers of this component
let selectionChanged = new CustomEvent(this.target + '-changed', { bubbles: true, detail: {selection: this.selectedItems, target: this.target} });
document.dispatchEvent(selectionChanged);
}
},
isSelected(itemId) {
let arr = selectedItems.split(',');
return arr.includes(itemId);
},
scrollToSelectedItem(navigatorOpen) {
if (navigatorOpen && this.selectedItems && 0 != this.selectedItems ) {
/* We need a very small delay, otherwise we attempt to scroll before the navigator is visible */
setTimeout(() => {
let selectItemsArray = this.selectedItems.split(',');
let targetItem = document.getElementById(selectItemsArray[0]);
if ( ! targetItem ) {
return;
}
let treeViewBody = document.querySelector('.gnt-tree-view-body');
treeViewBody.scrollTop = targetItem.offsetTop - treeViewBody.clientHeight;
}, 50);
}
}
}));
});
</script>
@endpush
@endonce
There are quite a few points about the code of the view that are worth explaining.
The target
property is used for three key purposes.
- It is built into the
wire:key
for theTreeViewItem
components, enabling unique keys even with multipleTreeView
components on the same page. The$item->id
property is all added to the key to provide uniqueness across items in the sameTreeView
. Unique keys among all instances of the same Livewire component are vital. If you ever get JavaScript errors relating to trying to access a property (oftenfingerprint
) onnull
, the cause is likely to be non-unique keys on your page. - It is passed as a property to the
TreeViewItem
components, to allow them to create uniquewire:key
values for nestedTreeViewItem
components. - It is passed as a property to all dispatched events, allowing the listening code to determine the relevance of the event.
- It is used in the name of the "changed" event that is dispatched when there is a change to the selected item(s).
The init()
method uses the Alpine JS $watch
"magic method" to call the scrollToSelectedItem()
method of the Alpine component when the value of showNavigator
changes. The scrollToSelectedItem()
method simply tries to scroll the first selected item into view within the TreeView
component. Note that I had to add a very small (50 ms) delay to the code that performs the scrolling. Without this, it attempts to scroll before the TreeView
is rendered and, thus, fails to scroll.
There is also an x-init on the Alpine component. This is because the <script>
tag is pushed to the child-scripts
stack once but the values of target
and multiSelect
need to be specific for each instance of TreeView
.
The isSelected()
method is use only for styling. I will point out how when we get to the view of the TreeViewItem
component.
The main work is done in the recordSelection
method, which is called via the custom item-clicked
event handler, which is emitted by the TreeViewItem
component. If the TreeView
is in single-select mode, the previously selected item is deselected and the click item selected. In multi-select mode, if the clicked item is already selected, it is deselected, otherwise, it is selected. In all cases, custom events are dispatched when an item is deselected (item-deselected
) or selected (item-selected
). In addition, if any change to the selection has been made, another custom event ({target}-changed
) is dispatched.
TreeViewItem
component
The The TreeViewItem
component is a Livewire component because each component's content need to be dynamically loaded if the item is expanded (and not already loaded).
class TreeViewItem extends Component
{
public HierarchicalModel $item;
public int $index;
public int $level;
public string $target;
public string $modelComponent;
public bool $hasChild;
public bool $expanded;
public bool $selected;
public $children = null;
protected $listeners = [
'parentChanged' => 'refreshChildren',
'refreshChildren' => 'refreshChildren',
'move' => 'move',
];
public function mount(string $modelComponent, HierarchicalModel $item, int $index=0, int $level=1, string $target='')
{
$this->item = $item;
$this->index = $index;
$this->level = isset($item->level) ? $item->level : $level;
$this->target = $target;
$this->modelComponent = $modelComponent;
$this->hasChild = $this->item->hasChild();
$this->expanded = isset($item->expanded) ? $item->expanded : false;
$this->selected = isset($item->selected) ? $item->selected : false;
$this->children = isset($item->childItems) ? $item->childItems : null;
}
public function render()
{
if ($this->expanded && $this->hasChild && empty( $this->children)) {
if ($this->hasChild && $this->expanded && empty( $this->children)) {
$this->children = $this->item->children;
}
}
return view('livewire.tree-view-item');
}
public function refreshChildren($ids)
{
if (in_array($this->item->id, $ids, true)) {
$this->children = $this->item->children;
$this->hasChild = count($this->children) > 0;
}
}
public function move(int $id, int $newSequenceNo)
{
if (
empty($this->item->sequence_no) ||
$this->item->id != $id ||
$this->item->sequence_no === $newSequenceNo ||
$newSequenceNo < 1
) {
return;
}
// Get the siblings of the model (this includes the current model)
$siblings = $this->item->siblings;
if (count($siblings) <= 1 || $newSequenceNo > count($siblings)) {
return;
}
/* As we only move one place at a time, we just need to swap positions */
$movedItems = [['id' => $id, 'sequenceNo' => $newSequenceNo]];
$parent_global_sequence = $this->item->parent->global_sequence;
foreach ($siblings as $sibling) {
if ($sibling->sequence_no != $newSequenceNo) {
continue;
}
$sibling->sequence_no = $this->item->sequence_no;
$sibling->global_sequence = $parent_global_sequence . '.' . $sibling->sequence_no;
$sibling->save();
$movedItems[]=['id' => $sibling->id, 'sequenceNo' => $sibling->sequence_no];
break;
}
$this->item->sequence_no = $newSequenceNo;
$this->item->global_sequence = $parent_global_sequence . '.' . $newSequenceNo;
$this->item->save();
$this->emitUp('refreshChildren', [$this->item->parent_id]);
foreach ($movedItems as $movedItem) {
$this->emit('sequenceChanged', $movedItem);
}
}
}
The majority of code in this class is concerned with tree views that allow editing. I will cover that in a separate section below because, if you don't need it, you can skip it. For now, I will just explain the basic functionality for getting the dynamic TreeView
to work.
The most important property is $item
, which is type-hinted using HierarchicalModel
. The index
property determines the sequence of the item among its siblings. The level
property is the depth of the item within in the tree. I have explained the target
property already. It is passed from the TreeView
component and onto all child TreeViewItem
components. The modelComponent
property is used to provide dynamic content for each item. I will explain that after I show you the view.
The render()
method lazy-loads the children of the item, if it is expanded. That is, if its children property has not yet been set, it sets it to the children property of the $item
, which is an Eloquent model so will, if not already fetched, fetch the child models from the database.
The view is like this.
<div>
<div
x-data="{index: {{ $index }}, level: {{ $level }}, selected: @entangle('selected'), expanded: @entangle('expanded') }"
x-cloak
:class="{'pl-4': level > 1, '-mt-px': level > 1 || index > 0}"
class="pr-0 w-full"
x-on:item-selected.window="if ($event.detail.item == {{ $item->id }} && $event.detail.target == '{{ $target }}') {selected = true;}"
x-on:item-deselected.window="if ($event.detail.item == {{ $item->id }} && $event.detail.target == '{{ $target }}') {selected = false;}"
>
<div :class="{'bg-gn-green bg-opacity-30 font-semibold': selected}" class="py-1 px-2 border flex items-center">
@if ($hasChild)
<div x-show="! expanded" class="hover:opacity-50" x-on:click="expanded = true">
<x-tree-view-expand />
</div>
<div x-show="expanded" class="hover:opacity-50" x-on:click="expanded = false">
<x-tree-view-collapse />
</div>
@else
<div>
<x-tree-view-leaf />
</div>
@endif
<div id="{{ $item->id }}" class="w-full ml-1" x-on:click="$dispatch('item-clicked', { item: {{ $item->id }} })">
<x-dynamic-component :component="$modelComponent" :item=$item />
</div>
</div>
@if ($hasChild)
<div x-show="expanded" class="border-l border-dashed">
@php
$itemLevel = $level + 1;
@endphp
<div>
@if ( empty($children) )
<div class="py-1 px-4">Loading...</div>
@else
@foreach ($children as $idx => $item)
<livewire:tree-view-item
:model-component="$modelComponent"
:item=$item
:index=$idx
:level=$itemLevel
:target=$target
:wire:key="'{{ $target }}-t-v-i-' . $item->id"
/>
@endforeach
@endif
</div>
</div>
@endif
</div>
</div>
The level
and index
properties are used to help style the tree view.
:class="{'pl-4': level > 1, '-mt-px': level > 1 || index > 0}"
The Alpine JS x-bind
(shorthand is just ':') allows us to conditionally add classes to the item. 'pl-4': level > 1
indents everything but the top level. '-mt-px': level > 1 || index > 0
adds the Tailwind CSS "-mt-px" class (margin-top: -1px;
) to every item but the first to prevent double borders between items.
There are two event listeners for the custom item-selected
and item-deselected
. All they do is set the selected
property of the item, which is used only for styling, via:
:class="{'bg-gn-green bg-opacity-30 font-semibold': selected}"
Note that bg-gn-green
is based on a custom colour (gn-green
) added in my tailwind.config.js
.
Each item has two parts: an icon and a x-dynamic-component
based on the modelComponent
property of the item, plus its children, if it has any. There are three icon components to cover the states of:
- no children
- collapsed children
- expanded children
Laravel Blade's built-in dynamic components allow their content to be bound at run time.
If the item has a child a div is added to contain the children but is only shown when the item's expanded
property is true
. Once expanded, if the children
property of the item has not yet been filled, the text 'Loading...' is displayed. With the reactive nature of Livewire, this is very soon replaced by the child TreeViewItem
components, once their data has been fetched and the components rendered.
TreeView
component
How I use the Taxon Navigator
The Taxon Navigator is the simplest implementation. There is no separate taxon navigator component just an instance of the TreeView
component created in the search-navigate
Blade component.
<x-tree-view model-name="Taxon" model-component="taxon-navigator-item" target="taxon-navigator" :selectedItems="$taxon" />
Note that the selectedItems
property is used to allow the page to provide a taxon ID, if applicable. This is done by setting a $taxon
property on the main app-layout
view that is passed on to the search-navigate
component. For example, this is used from the Taxa
show page so that when the Taxon Navigator is opened the current taxon is pre-selected and shown, so it is easier to navigate to a related taxon.
The taxon-navigator-item
Blade component is like this.
<a href="{{ url('/taxa/' . $item->id) }}">
{{ $item->title }}
</a>
Basically, each item is a link to the corresponding Taxa
show page.
The Root Taxon Picker
The root-taxon-picker
Blade component is like this.
<div class="bg-white mt-1 rounded-lg shadow-lg p-4 w-full md:container" >
<x-tree-view model-name="Taxon" model-component="taxon-tree-view-item" target="root-taxon" />
</div>
The taxon-tree-view-item
Blade component is like this.
<span>{{ $item->title }}</span>
Each item is a simple span
but clicking an item will cause an 'item-clicked' event to be dispatched, which will be picked up by the TreeView
component, which will call its recordSelection()
method and, if necessary, dispatch a {target}-changed
custom event, which in the case of the root-taxon-picker
Blade component will always be root-taxon-changed
. This is listened for by other components. For example, the ObservationFilter
Livewire component listens for this event and restricts the display of observations to those within the chosen taxon. The ChecklistForm
Livewire component uses this to set the top-level taxon for the checklist, which then restricts which taxa can be excluded and what photo can be selected.
The Exclude Taxa Picker
The exclude-taxa-picker
Blade component is almost the same as the root-taxon-picker
component.
<x-tree-view model-name="Taxon" model-component="taxon-tree-view-item" target="exclude-taxa" :root=$rootTaxon multi-select=true />
The only differences are the value of the target
property and that this component allows multi-select.
This component is used only in the definition of checklists, where it is possible to exclude one or more taxa from within a higher level taxon. I will use moths as an example to explain what this is for. Moths are my most species rich group (that is, based on what I have managed to photograph). Some subsets of moths are far more species rich than others. For example, I have loads of species in the tribe Lithosiini, which is in the subfamily Arctiinae in the family Erebidae. Erebidae is a huge family (again, based on my recorded observations). I don't have very diverse records for other tribes in Arctiinae. I also have fairly diverse records in the subfamily Boletobiinae (also in Erebidae). So, I have the following checklists for Erebidae:
- Tiger Moths and Allies (Arctiinae)
- RootTaxon: 47606
- ExcludedTaxa: ''
- Boletobiinae
- RootTaxon: 82036
- ExcludedTaxa: ''
- Other Erebid moths
- RootTaxon: 121850
- ExcludedTaxa: '47606,82036'
Article and Checklist Categories (Editable Tree View)
The functionality for article and checklist categories are the same as each other, just with different component names and models. Using checklist categories as the example, the TreeView
component is used like this:
<x-tree-view model-name="ChecklistCategory" model-component="checklist-category-editable-list-item" target="checklist-category" :multi-select=true />
The resulting checklist categories are like this.
As the image above suggests, the checklist-category-editable-list-item
Blade component (rendered via x-dynamic-component
is a little more complex than those of the other TreeView
implementations. In fact, that is only indirectly true. The checklist-category-editable-list-item
component is very simple because all it does is render a Livewire component.
<livewire:checklist-category-list-item :checklist-category="$item" :wire:key="$item->id" />
The complexity is in the CategoryListItem
Livewire component, which is a generic component that is extended by the ArticleCategoryListItem
and ChecklistCategoryListItem
components.
class CategoryListItem extends Component
{
public ?CategoryModel $category;
public int $sequenceNo;
public int $maxSequenceNo;
public bool $hasChild;
public bool $showForm = false;
public string $formComponent;
protected $listeners = ['sequenceChanged', 'categoryFormSubmitCompleted'];
public function mount(CategoryModel $category = null)
{
$this->category = $category;
$this->sequenceNo = $category->sequence_no;
$this->hasChild = $category->hasChild();
$modelClass = get_class($this->category);
if ( empty($this->category->parent_id) ) {
$this->maxSequenceNo = $modelClass::whereNull('parent_id')->max('sequence_no');
} else {
$this->maxSequenceNo = $modelClass::find($this->category->parent_id)->next_sequence_no - 1;
}
}
public function render()
{
return view('livewire.category-list-item');
}
public function sequenceChanged($args)
{
if ($this->category->id === $args['id']) {
$this->sequenceNo = $args['sequenceNo'];
}
}
public function categoryFormSubmitCompleted(int $id)
{
if ($this->category->id === $id) {
$this->showForm = false;
}
}
}
To enable this component to be used for article and checklist categories, the model is type hinted with the CategoryModel
abstract class.
The maxSequenceNo
property is used to restrict movement of categories within their parent. The showForm
property determines whether or not the CategoryForm
component for the item is displayed. Declaring it in the component, allows us to control it there. The sequenceNo
and hasChild
properties of the category model that are used in @entangle
directives in the view. The formComponent
property allows the specific implementation of the CategoryForm
component to be set by the extending classes.
The component listens to two events: sequenceChanged
and categoryFormSubmitCompleted
. The former is emited when a category is moved up or down within its parent; and the latter when the submit()
method of the CategoryForm
component is completed, with the sole purpose of allowing us to hide the form.
The view is like this.
<div>
<div
x-data="{
showForm: @entangle("showForm"),
sequenceNo: @entangle("sequenceNo"),
hasChild: @entangle("hasChild")
}"
class="w-full flex justify-between items-center"
x-cloak
>
<div>{{ $category->title }}</div>
<div class="flex space-x-1">
<div x-on:click="showForm = true" class="cursor-pointer"><x-icons.pencil class="w-4 h-4" /></div>
<div
:class="{
'opacity-50 cursor-not-allowed': hasChild,
'cursor-pointer': ! hasChild
}"
onclick="confirm('Are you sure?') || event.stopImmediatePropagation()"
wire:click="$emitUp('delete', {{ $category->id }})"
>
<x-icons.remove class="w-4 h-4" />
</div>
<div
:class="{
'opacity-50 cursor-not-allowed': sequenceNo === 1,
'cursor-pointer': sequenceNo > 1
}"
wire:click="$emitUp('move', {{ $category->id }}, {{ $category->sequence_no - 1 }})"
>
<x-icons.up class="w-4 h-4" />
</div>
<div
:class="{
'opacity-50 cursor-not-allowed': sequenceNo === {{ $maxSequenceNo }},
'cursor-pointer': sequenceNo < {{ $maxSequenceNo }}
}"
wire:click="$emitUp('move', {{ $category->id }}, {{ $category->sequence_no + 1 }})"
>
<x-icons.down class="w-4 h-4" />
</div>
</div>
<div
x-show.transition="showForm"
x-on:keydown.escape="showForm = false"
class="fixed top-0 bottom-0 left-0 right-0 bg-gray-800 bg-opacity-50 z-40"
>
<div
x-on:click.away="showForm = false"
class="sm:w-96 mx-auto mt-40 p-3 border rounded-md bg-white"
>
<x-dynamic-component :component="$formComponent" :category="$category" />
</div>
</div>
</div>
</div>
Basically, each catogery item shows its title followed by 4 icons: a pencil for editing, a cross deleting, an arrow for moving up and another arrow moving down. Each item also has a hidden modal form. The specific form depends on the category type and is loaded via x-dynamic-component
. Clicking the pencil shows the form. As you can see from the screenshot above, some of the icons appear "disabled". This is achieved by binding classes. The cross for deleting is "disabled" if the category has a child category.
:class="{
'opacity-50 cursor-not-allowed': hasChild,
'cursor-pointer': ! hasChild
}"
The arrow for moving an item up, is "disabled" when the item is already in the first position within its parent.
:class="{
'opacity-50 cursor-not-allowed': sequenceNo === 1,
'cursor-pointer': sequenceNo > 1
}"
The arrow for moving an item down, is "disabled" when the item is already in the last position within its parent.
:class="{
'opacity-50 cursor-not-allowed': sequenceNo === {{ $maxSequenceNo }},
'cursor-pointer': sequenceNo < {{ $maxSequenceNo }}
}"
The above code doesn't actually disable anything, it just gives visual indication that the actions are disabled. The logic for actually disabling the actions is in the code of the component, in this case the TreeViewItem
Livewire component, which listens for the events emited by the CategoryListItem
component. Clicking the delete icon, emits a delete
event.
wire:click="$emitUp('delete', {{ $category->id }})"
This invokes the TreeViewItem->delete()
method.
public function delete(int $id)
{
if ($this->item->id != $id || $this->item->hasChild()) {
return;
}
$this->item->delete();
}
This simply checks it is the right item and that is has not child item, and then deletes the item.
Clicking the move up icon, emits a move event passing the current sequence number minus 1.
wire:click="$emitUp('move', {{ $category->id }}, {{ $category->sequence_no - 1 }})"
Similarly, the move down icon, emits a move event passing the current sequence number plus 1.
wire:click="$emitUp('move', {{ $category->id }}, {{ $category->sequence_no + 1 }})"
Both cases invoke the TreeViewItem->move()
method.
public function move(int $id, int $newSequenceNo): void
{
if (
empty($this->item->sequence_no) ||
$this->item->id != $id ||
$this->item->sequence_no === $newSequenceNo ||
$newSequenceNo < 1
) {
return;
}
// Get the siblings of the model (this includes the current model)
$siblings = $this->item->siblings;
if (count($siblings) <= 1 || $newSequenceNo > count($siblings)) {
return;
}
/* As we only move one place at a time, we just need to swap positions */
$movedItems = [['id' => $id, 'sequenceNo' => $newSequenceNo]];
foreach ($siblings as $sibling) {
if ($sibling->sequence_no != $newSequenceNo) {
continue;
}
$movedItems[]=['id' => $sibling->id, 'sequenceNo' => $sibling->sequence_no];
break;
}
$this->item->sequence_no = $newSequenceNo;
DB::beginTransaction();
try {
$wasSavedSuccessfully = $this->item->save();
if (!$wasSavedSuccessfully) {
DB::rollBack();
return;
}
DB::commit();
$this->emitUp('refreshChildren', [$this->item->parent_id]);
foreach ($movedItems as $movedItem) {
$this->emit('sequenceChanged', $movedItem);
}
} catch ( \Throwable $ex ) {
DB::rollBack();
Log::error($ex->getMessage());
}
}
Basically, it checks whether or not a move is applicable, finds the sibling item in the position to which it is moving and swaps positions. This is pretty simple because we are only moving one position at a time. If I had implemented drag-and-drop, it would be possible to move multiple places at once and need to change the positions of multiple siblings. Note that the new sequence_no
is set only for the item we moved. The HierarchicalModel::save()
method ensures the other item is updated, too. After swapping the items, it emits three events: one refreshChildren
event passing the ID of the parent of the moved items to trigger it to re-render; and two sequenceChanged
events (one for each moved item) so each respective CategoryListItem
components can update its sequenceNo
property.
The specific implementations of the CategoryListItem
component (i.e. the ArticleCategoryListItem
and ChecklistCategoryListItem
components) are like this.
class ArticleCategoryListItem extends CategoryListItem
{
public function mount(CategoryModel $articleCategory = null)
{
$this->formComponent = "article-category-form";
parent::mount($articleCategory);
}
}
It simply specifies the component to use for the category form and calls the parent's mount()
method. There is no associated view because the generic view is rendered by the parent CategoryListItem
component.
The Category Form
For completeness, I will briefly show how the category forms are implemented. In a similar manner to CategoryListItem
component, there is a base CategoryForm
component that is extended by the ArticleCategoryForm
and ChecklistCategoryForm components.
class CategoryForm extends Component
{
public ?CategoryModel $category;
public $categories;
public bool $showForm = false;
public bool $hasSlug = false;
protected $rules = [
'category.title' => 'required',
'category.sequence_no' => 'required',
/* The rule on parent_id is to prevent CannotBindToModelDataWithoutValidationRuleException */
'category.parent_id' => '',
];
public function render()
{
return view('livewire.category-form');
}
public function updated($propertyName)
{
$this->validateOnly($propertyName);
}
public function submit()
{
$oldParentId = $this->category->getRawOriginal('parent_id');
$oldTitle = $this->category->getRawOriginal('title');
if (empty($this->category->sequence_no) || $this->category->parent_id !== $oldParentId) {
$modelClass = get_class($this->category);
if (empty($this->category->parent_id)) {
$currentMax = $modelClass::whereNull('parent_id')->max('sequence_no');
if (empty($currentMax)) {
$currentMax = 0;
}
$this->category->sequence_no = $currentMax + 1;
} else {
$parentCategory = $modelClass::find($this->category->parent_id);
$this->category->sequence_no = $parentCategory->next_sequence_no;
}
}
$this->validate();
$is_new = empty($this->category->id);
/* Ensure everything is done in a single transaction, so we can roll back if anything goes wrong */
DB::beginTransaction();
$wasSavedSuccessfully = $this->category->save();
if (!$wasSavedSuccessfully) {
DB::rollBack();
session()->flash('failure', 'Failed to save category.');
return;
}
DB::commit();
session()->flash('success', 'Category successfully saved.');
$modelClass = get_class($this->category);
$this->categories = $modelClass::orderBy('set_start', 'asc')->get();
if ($is_new) {
/* If we added a new category, emit an event so its parent knows to re-render. */
if ( !empty($this->category->parent_id) ) {
$this->emitTo('tree-view-item', 'refreshChildren', [(int) $this->category->parent_id]);
}
} else {
if ( $this->category->parent_id !== $oldParentId ) {
/* If we changed parent, emit an event so the old and new parents know to re-render. */
$this->emitTo('tree-view-item', 'parentChanged', [(int) $oldParentId, (int) $this->category->parent_id]);
}
if ( $this->category->title !== $oldTitle ) {
/* If the title changed, emit an event so the item knows to re-render. */
$this->emitTo('tree-view-item', 'titleChanged', $this->category->id);
}
}
$this->emitUp('categoryFormSubmitCompleted', $this->category->id);
}
}
And the ArticleCategoryForm
component is like this.
class ArticleCategoryForm extends CategoryForm
{
public function mount(ArticleCategory $articleCategory = null)
{
$this->category = $articleCategory;
$this->categories = ArticleCategory::orderBy('global_sequence', 'asc')->get();
}
}
This allows the specific class to mount the component using the correct specific model class. The categories
property is purely to provide a list of categories in a <select>
list for choosing the parent of the category.
Moving back up to the base component, first it records some existing values so we can emit events if they have changed. The next part is for adding new categories, for which there will not be a sequence_no
. It just gets the next available sequence number based on the parent. If no parent is specified, it gets the next sequence based on the top-level items. It then validates and saves the category model before emitting events to allow other components to re-render.
The view for the base component is like this.
<div>
<form wire:submit.prevent="submit">
@csrf
<x-input type="hidden" wire:model="category.id" />
<div>
<x-label for="title" :value=" __('Title')" />
<x-input type="text" id="title" wire:model.lazy="category.title" class="w-full" />
@error('title') <span class="error">{{ $message }}</span> @enderror
</div>
<div class="mt-3">
<x-label for="parent" :value=" __('Parent')" />
<select id="parent" wire:model.lazy="category.parent_id" class="w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option>** Top Level **</option>
@foreach ($categories as $option)
<option value="{{ $option->id }}" >{{ $option->indent . $option->title }}</option>
@endforeach
</select>
</div>
<div class="flex items-center justify-end mt-3">
<x-button class="opacity-50 mr-1" type="button" x-on:click="showForm = false">Cancel</x-button>
<x-button type="submit">Submit</x-button>
</div>
</form>
</div>
You can see it is a very simple form with just a title and <select>
list of categories from which to pick a parent. I could have implemented a TreeView
component here for the selection of parent but decided to use a simple <select>
because I am unlikely to have very many categories. To make the levels obvious in the list, I implemented a getIdentAttribute()
method on the category Model
classes via CategoryModel
class which they both use. It extends the HierarchicalModel
class.
abstract class CategoryModel extends HierarchicalModel
{
/* Get the next available sequence_no for children of this category */
public function getNextSequenceNoAttribute()
{
if ( count($this->children ) === 0 ) {
return 1;
}
$currentMax = self::where('parent_id', $this->id)->max('sequence_no');
return $currentMax + 1;
}
/* Get a string to indent the category in a list of options */
public function getIndentAttribute()
{
return str_repeat('-', ($this->depth - 1) * 2);
}
}
Note that this also has a getNextSequenceNoAttribute()
method, which is used in the CategoryListItem
component.
$this->maxSequenceNo = $modelClass::find($this->category->parent_id)->next_sequence_no - 1;
Wrapping Up
So, that ended up very long and covering quite a bit. Obviously, the main point is the TreeView
Blade component with its TreeViewItem
Livewire components, that lazy-load when expanded for the first time. It also supports preloading and expanding items to display and highlight one or more pre-selected items. Also crucial to this implementation is the use of x-dynamic-component
, which allows for a single component to be used for multiple purposes. Here, I use simple titles, links and editable items. The use of subclasses of the Illuminate\Database\Eloquent\Model
class also helps to minimise code duplication and ensure consistency across the different model classes used by the tree views. I also touched on creating base Livewire components that can be extended, again, greatly reducing code duplication. There is also quite a lot of use of events, which enable the different components to communicate with each other and make sure everything stays nice and reactive for the user.