<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
solution-forest / filament-nestable-tree example snippets
use SolutionForest\FilamentNestableTree\Filament\Pages\TreePage;
use SolutionForest\FilamentNestableTree\Tree;
class CategoryTreePage extends TreePage
{
protected static ?string $navigationLabel = 'Categories';
public function tree(Tree $tree): Tree
{
return $tree->model(Category::class)->labelField('title');
}
}
use SolutionForest\FilamentNestableTree\Filament\Resources\Pages\TreePage;
use SolutionForest\FilamentNestableTree\Tree;
class ManageCategoryTree extends TreePage
{
public static string $resource = CategoryResource::class;
public function tree(Tree $tree): Tree
{
return parent::tree($tree) //
public static function getPages(): array
{
return [
'index' => ManageCategoryTree::route('/'),
];
}
use SolutionForest\FilamentNestableTree\Filament\Widgets\Tree as TreeWidget;
use SolutionForest\FilamentNestableTree\Tree;
class CategoryTreeWidget extends TreeWidget
{
public function tree(Tree $tree): Tree
{
return $tree->model(Category::class)->labelField('title');
}
}
use Livewire\Component;
use SolutionForest\FilamentNestableTree\Concerns\InteractsWithTree;
use SolutionForest\FilamentNestableTree\Tree;
class MyCustomPage extends Component
{
use InteractsWithTree;
public function tree(Tree $tree): Tree
{
return $tree->model(Category::class)->labelField('title');
}
}
use Kalnoy\Nestedset\NodeTrait;
class Category extends Model
{
use NodeTrait;
}
public function tree(Tree $tree): Tree
{
return $tree->model(Category::class)->labelField('title');
}
public function tree(Tree $tree): Tree
{
return $tree
->model(Category::class)
->saveOrderUsing(function (array $nodes): void {
// $nodes is the full nested array from Alpine
foreach ($nodes as $index => $node) {
Category::where('id', $node['id'])->update(['sort_order' => $index]);
}
});
}
use Filament\Actions\Action;
public function tree(Tree $tree): Tree
{
return $tree
->appendToolbarActions([
Action::make('save_order')
->label('Save')
->icon('heroicon-o-check')
->extraAttributes(['x-show' => 'hasUnsavedOrder', 'x-cloak' => true])
->action('saveOrder'),
]);
}
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
public function tree(Tree $tree): Tree
{
return $tree
->model(Category::class)
->nodeActions([
EditAction::make()
->iconButton()
->icon('heroicon-o-pencil')
->size('sm'),
DeleteAction::make()
->iconButton()
->icon('heroicon-o-trash')
->size('sm')
->color('danger'),
]);
}
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
->nodeActions(fn (Tree $tree) => [
Action::make('rename')
->iconButton()
->icon('heroicon-o-pencil')
->schema([TextInput::make('title')->nts['nodeId'] is the node's primary key
$record->update(['title' => $data['title']]);
})
->after(fn ($livewire) => $livewire->dispatch('tree-refresh')),
])
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
public function tree(Tree $tree): Tree
{
return $tree
->model(Category::class)
->appendToolbarActions([
CreateAction::make('create_node')
->model(Category::class)
->schema(fn (Schema $schema) => $this->form($schema))
->after(fn ($livewire) => $livewire->dispatch('tree-refresh'))
->extraAttributes(['style' => 'margin-left: auto;']),
ActionGroup::make([
Action::make('import')->label('Import'),
Action::make('export')->label('Export'),
])->label('More'),
]);
}
use SolutionForest\FilamentNestableTree\Filament\Pages\TreePage;
use SolutionForest\FilamentNestableTree\Tree;
class MultiTreePage extends TreePage
{
public function trees(): array
{
return [
'categories' => Tree::make()->model(Category::class)->searchable()->labelField('title'),
'tags' => Tree::make()->model(Tag::class)->searchable()->labelField('name'),
];
}
}
class MultiTreePage extends TreePage
{
public function trees(): array
{
$electronicsId = Category::where('title', 'Electronics')->value('id');
$clothingId = Category::where('title', 'Clothing')->value('id');
$createAction = fn (string $category) => CreateAction::make('create_' . $category)
->iconButton()
->icon('heroicon-o-plus')
->after(fn ($livewire) => $livewire->dispatch('tree-refresh'))
->schema([
TextInput::make('name')->ategoryId,
]);
});
return [
'technology' => Tree::make()
->records(fn () => Tag::where('category_id', $electronicsId)
->defaultOrder()->get()->toTree()->toArray())
->labelField('name')
->allowCrossCategory()
->saveOrderUsing(fn (array $nodes) => Tag::rebuildTree($nodes))
->appendToolbarActions([$createAction('technology')]),
'science' => Tree::make()
->records(fn () => Tag::where('category_id', $clothingId)
->defaultOrder()->get()->toTree()->toArray())
->labelField('name')
->allowCrossCategory()
->saveOrderUsing(fn (array $nodes) => Tag::rebuildTree($nodes))
->appendToolbarActions([$createAction('science')]),
];
}
/**
* Called automatically when a node is dragged from one tree to another.
*/
public function handleCrossTreeMove(
string $fromTreeKey,
string $toTreeKey,
int|string $nodeId,
mixed $destinationParentId = null,
): void {
$tag = Tag::find($nodeId);
if (! $tag) {
return;
}
$newCategoryTitle = $this->treeCategories[$toTreeKey] ?? null;
$newCategoryId = $newCategoryTitle
? Category::where('title', $newCategoryTitle)->value('id')
: null;
if ($destinationParentId) {
$parent = Tag::find($destinationParentId);
if ($parent) {
$tag->appendToNode($parent)->save();
}
} else {
$tag->saveAsRoot();
}
if ($newCategoryId) {
$tag->update(['category_id' => $newCategoryId]);
}
$this->dispatch('tree-refresh');
}
}
class CategoryPartitionedTreePage extends TreePage
{
/** Flat node store — replace with database reads in production. */
public static array $nodes = [];
private const TREE_CATEGORY_MAP = ['tree1' => 1, 'tree2' => 2];
protected $listeners = ['tree-cross-move' => 'handleCrossTreeMove'];
public function trees(): array
{
return [
'tree1' => Tree::make()
->labelField('title')
->allowCrossCategory()
->records(fn () => $this->asTree(
collect(static::$nodes)->where('category_id', 1)->values()->all()
))
->saveOrderUsing($this->saveOrderForCategory(1)),
'tree2' => Tree::make()
->labelField('title')
->allowCrossCategory()
->records(fn () => $this->asTree(
collect(static::$nodes)->where('category_id', 2)->values()->all()
))
->saveOrderUsing($this->saveOrderForCategory(2)),
];
}
/**
* Flatten + tag each saved node with its category, then merge back with
* nodes that belong to other categories so nothing gets lost on save.
*/
private function saveOrderForCategory(int $categoryId): Closure
{
return function (array $nodes) use ($categoryId): void {
$saved = collect($this->asFlatten($nodes))
->map(fn ($n) => array_merge($n, ['category_id' => $categoryId]))
->all();
$others = collect(static::$nodes)
->filter(fn ($n) => ($n['category_id'] ?? null) != $categoryId)
->values()
->all();
static::$nodes = array_merge($others, $saved);
};
}
/**
* Update the partition field (category_id) and parent_id when a node is
* dragged between trees. Silently ignored for unknown tree keys.
*/
public function handleCrossTreeMove(
string $fromTreeKey,
string $toTreeKey,
int|string $nodeId,
mixed $destinationParentId = null,
): void {
$destCategory = self::TREE_CATEGORY_MAP[$toTreeKey] ?? null;
if ($destCategory === null) {
return;
}
static::$nodes = collect(static::$nodes)
->map(function ($node) use ($nodeId, $destCategory, $destinationParentId) {
if ((string) $node['id'] === (string) $nodeId) {
$node['category_id'] = $destCategory;
$node['parent_id'] = $destinationParentId;
}
return $node;
})
->all();
$this->dispatch('tree-refresh');
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Flat parent_id array → nested children array. */
private function asTree(array $flat): array
{
$map = [];
foreach ($flat as $item) {
$map[$item['id']] = $item + ['children' => []];
}
$tree = [];
foreach ($map as $id => &$node) {
if ($node['parent_id'] === null || ! isset($map[$node['parent_id']])) {
$tree[] = &$node;
} else {
$map[$node['parent_id']]['children'][] = &$node;
}
}
return $tree;
}
/** Nested children array → flat array (strips children key). */
private function asFlatten(array $tree): array
{
$flat = [];
foreach ($tree as $item) {
$children = $item['children'] ?? [];
unset($item['children']);
$flat[] = $item;
if (! empty($children)) {
$flat = array_merge($flat, $this->asFlatten($children));
}
}
return $flat;
}
}
class Post extends Model
{
public function children(): HasMany
{
return $this->hasMany(Post::class, 'parent_id')->orderBy('order')->with('children');
}
}
public function tree(Tree $tree): Tree
{
return $tree
->model(Post::class)
->labelField('name')
->parentKeyField('parent_id')
->saveOrderUsing(function (array $nodes): void {
$this->saveOrder($nodes);
});
}
private function saveOrder(array $nodes, ?int $parentId = null, int $start = 0): void
{
foreach ($nodes as $index => $node) {
Post::where('id', $node['id'])->update([
'parent_id' => $parentId,
'order' => $start + $index,
]);
if (! empty($node['children'])) {
$this->saveOrder($node['children'], (int) $node['id'], 0);
}
}
}