TALL Stack: Entangled Tree View

Date Created: 18-Apr-2022
(Last Updated: 09-Jul-2022)

Twisted Mangrove Roots

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.

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.

The TreeView component

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 the TreeViewItem components, enabling unique keys even with multiple TreeView components on the same page. The $item->id property is all added to the key to provide uniqueness across items in the same TreeView. 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 (often fingerprint) on null, 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 unique wire:key values for nested TreeViewItem 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.

The TreeViewItem component

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.

How I use the TreeView component

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.

Checklist Categories

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.