Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,72 @@ SelectTree::make('category_id')
->query(fn() => Category::query(), 'name', 'parent_id')
```

## Custom Query
## Query scoping

Customize the parent query
### `scopeRelationshipQueryUsing`

Runs on **every** query used to build the tree: walking descendants, loading root rows, and loading child rows. Put **tenant or locale filters**, **`select()`**, **`with()`**, and any constraint that must hold for **all** nodes here.

```php
use CodeWithDennis\FilamentSelectTree\SelectTree;
use Illuminate\Database\Eloquent\Builder;

SelectTree::make('categories')
->relationship(relationship: 'categories', titleAttribute: 'name', parentAttribute: 'parent_id', modifyQueryUsing: fn($query) => $query));
->scopeRelationshipQueryUsing(
fn (Builder $query): Builder => $query->where('language_code', app()->getLocale())
)
->relationship('categories', 'name', 'parent_id');
```

Customize the child query
Add tenant or team scoping the same way (e.g. `whereBelongsTo($tenant)` or `where('tenant_id', …)`).

If you narrow columns, include at least the tree key, parent column, title attribute, and any column used by `withKey()`:

```php
use CodeWithDennis\FilamentSelectTree\SelectTree;
use Illuminate\Database\Eloquent\Builder;

SelectTree::make('categories')
->scopeRelationshipQueryUsing(
fn (Builder $query): Builder => $query->select(['id', 'parent_id', 'name'])
)
->relationship('categories', 'name', 'parent_id');
```

### `modifyQueryUsing`

Optional. When set, it runs **only** on a clone of the **root** query to decide which root records seed each subtree (then IDs are clamped with `whereIn`). It is **not** merged into descendant queries—using root-only conditions as if they applied globally would contradict child rows and hide branches.

Use it for extra filters on which nodes count as roots (e.g. only published roots), and keep shared rules in `scopeRelationshipQueryUsing`.

```php
use CodeWithDennis\FilamentSelectTree\SelectTree;
use Illuminate\Database\Eloquent\Builder;

SelectTree::make('categories')
->relationship(
relationship: 'categories',
titleAttribute: 'name',
parentAttribute: 'parent_id',
modifyQueryUsing: fn (Builder $query): Builder => $query->where('is_featured', true),
);
```

### `modifyChildQueryUsing`

Runs only on the **non-root** query (rows whose parent is not the null parent value), after subtree clamping when `modifyQueryUsing` is used.

```php
use CodeWithDennis\FilamentSelectTree\SelectTree;
use Illuminate\Database\Eloquent\Builder;

SelectTree::make('categories')
->relationship(relationship: 'categories', titleAttribute: 'name', parentAttribute: 'parent_id', modifyChildQueryUsing: fn($query) => $query));
->relationship(
relationship: 'categories',
titleAttribute: 'name',
parentAttribute: 'parent_id',
modifyChildQueryUsing: fn (Builder $query): Builder => $query->orderBy('sort_order'),
);
```

## Methods
Expand Down
101 changes: 93 additions & 8 deletions src/SelectTree.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ class SelectTree extends Field implements HasAffixActions

protected ?Closure $modifyChildQueryUsing = null;

/**
* Runs on every tree query (descendant walk and both final loads). Use for tenant, locale,
* narrowed columns, eager loads, etc. Put shared constraints here instead of {@see modifyQueryUsing}.
*/
protected ?Closure $scopeRelationshipQueryUsing = null;

protected Closure|int $defaultOpenLevel = 0;

protected string $direction = 'auto';
Expand Down Expand Up @@ -169,20 +175,53 @@ protected function setUp(): void
$this->treeKey('treeKey-'.rand());
}

/**
* Fresh {@see getQuery()} clone with {@see scopeRelationshipQueryUsing} applied when set.
*/
protected function newRelationshipQuery(): Builder
{
$query = $this->getQuery()->clone();

if ($this->scopeRelationshipQueryUsing) {
$modified = $this->evaluate($this->scopeRelationshipQueryUsing, ['query' => $query]);
$query = $modified ?? $query;
}

return $query;
}

protected function buildTree(): Collection
{
// Start with two separate query builders
$nullParentQuery = $this->getQuery()->clone()->where($this->getParentAttribute(), $this->getParentNullValue());
$nonNullParentQuery = $this->getQuery()->clone()->whereNot($this->getParentAttribute(), $this->getParentNullValue());
$parentAttribute = $this->getParentAttribute();
$parentNullValue = $this->getParentNullValue();
$keyName = $this->getQuery()->getModel()->getKeyName();

$nullParentQuery = $this->newRelationshipQuery()->where($parentAttribute, $parentNullValue);
$nonNullParentQuery = $this->newRelationshipQuery()->whereNot($parentAttribute, $parentNullValue);

// If we're not at the root level and a modification callback is provided, apply it to null query
if ($this->modifyQueryUsing) {
$nullParentQuery = $this->evaluate($this->modifyQueryUsing, ['query' => $nullParentQuery]);
$rootQuery = $nullParentQuery->clone();
$modified = $this->evaluate($this->modifyQueryUsing, ['query' => $rootQuery]);
$rootQuery = $modified ?? $rootQuery;

if ($this->withTrashed) {
$rootQuery->withTrashed($this->withTrashed);
}

$rootIds = $rootQuery->pluck($keyName)->all();
$subtreeIds = $this->collectIdsInSubtrees($rootIds);

if ($subtreeIds === []) {
return $this->buildTreeFromResults(collect());
}

$nullParentQuery->whereIn($keyName, $subtreeIds);
$nonNullParentQuery->whereIn($keyName, $subtreeIds);
}

// If we're at the child level and a modification callback is provided, apply it to non null query
if ($this->modifyChildQueryUsing) {
$nonNullParentQuery = $this->evaluate($this->modifyChildQueryUsing, ['query' => $nonNullParentQuery]);
$modified = $this->evaluate($this->modifyChildQueryUsing, ['query' => $nonNullParentQuery]);
$nonNullParentQuery = $modified ?? $nonNullParentQuery;
}

if ($this->withTrashed) {
Expand All @@ -204,6 +243,42 @@ protected function buildTree(): Collection
return $this->buildTreeFromResults($combinedResults);
}

/**
* @param array<int|string> $rootIds
* @return array<int|string>
*/
protected function collectIdsInSubtrees(array $rootIds): array
{
if ($rootIds === []) {
return [];
}

$parentAttribute = $this->getParentAttribute();
$keyName = $this->getQuery()->getModel()->getKeyName();
$allIds = collect($rootIds);
$frontier = array_values(array_unique($rootIds, SORT_REGULAR));

while ($frontier !== []) {
$childQuery = $this->newRelationshipQuery()->whereIn($parentAttribute, $frontier);

if ($this->withTrashed) {
$childQuery->withTrashed($this->withTrashed);
}

$children = $childQuery->pluck($keyName)->all();
$newIds = array_values(array_diff($children, $allIds->all()));

if ($newIds === []) {
break;
}

$frontier = $newIds;
$allIds = $allIds->merge($newIds);
}

return $allIds->unique()->values()->all();
}

private function buildTreeFromResults($results, $parent = null): Collection
{
// Assign the parent's null value to the $parent variable if it's not null or default to empty string
Expand Down Expand Up @@ -322,6 +397,16 @@ public function relationship(string $relationship, string $titleAttribute, strin
return $this;
}

/**
* @param ?Closure(array{query: Builder}): (Builder|null) $callback
*/
public function scopeRelationshipQueryUsing(?Closure $callback): static
{
$this->scopeRelationshipQueryUsing = $callback;

return $this;
}

public function query(Builder|Closure|null $query, string $titleAttribute, string $parentAttribute, ?Closure $modifyQueryUsing = null, ?Closure $modifyChildQueryUsing = null): static
{
$this->query = $query;
Expand Down Expand Up @@ -392,7 +477,7 @@ public function append(Closure|array|null $append = null): static
} elseif (is_null($this->append)) {
// Avoid throwing an exception in case $append is explicitly set to null, or a Closure evaluates to null.
} else {
throw new \InvalidArgumentException('The provided append value must be an array with "name" and "value" keys.');
throw new InvalidArgumentException('The provided append value must be an array with "name" and "value" keys.');
}

return $this;
Expand Down