current()))
18 | x-load="visible || event (ax-modal-opened)"
19 | @else
20 | x-load
21 | @endif
22 | x-load-css="[@js(FilamentAsset::getStyleHref('filament-select-tree-styles', package: 'codewithdennis/filament-select-tree'))]"
23 | x-load-src="{{ FilamentAsset::getAlpineComponentSrc('filament-select-tree', package: 'codewithdennis/filament-select-tree') }}"
24 | x-data="selectTree({
25 | name: @js($getName()),
26 | state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
27 | options: @js($getTree()),
28 | searchable: @js($isSearchable()),
29 | showCount: @js($getWithCount()),
30 | placeholder: @js($getPlaceholder()),
31 | disabledBranchNode: @js(!$getEnableBranchNode()),
32 | disabled: @js($isDisabled()),
33 | isSingleSelect: @js(!$getMultiple()),
34 | isIndependentNodes: @js($getIndependent()),
35 | showTags: @js($getMultiple()),
36 | alwaysOpen: @js($getAlwaysOpen()),
37 | clearable: @js($getClearable()),
38 | emptyText: @js($getEmptyLabel()),
39 | expandSelected: @js($getExpandSelected()),
40 | grouped: @js($getGrouped()),
41 | openLevel: @js($getDefaultOpenLevel()),
42 | direction: @js($getDirection()),
43 | rtl: @js(__('filament-panels::layout.direction') === 'rtl'),
44 | })"
45 | >
46 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/FilamentSelectTreeServiceProvider.php:
--------------------------------------------------------------------------------
1 | name(static::$name)
18 | ->hasViews();
19 | }
20 |
21 | public function packageBooted(): void
22 | {
23 | FilamentAsset::register([
24 | AlpineComponent::make('filament-select-tree', __DIR__.'/../resources/dist/filament-select-tree.js'),
25 | Css::make('filament-select-tree-styles', __DIR__.'/../resources/dist/filament-select-tree.css'),
26 | ], 'codewithdennis/filament-select-tree');
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/SelectTree.php:
--------------------------------------------------------------------------------
1 | loadStateFromRelationshipsUsing(static function (self $component): void {
97 | // Get the current relationship associated with the component.
98 | $relationship = $component->getRelationship();
99 |
100 | // Check if the relationship is a BelongsToMany relationship.
101 | if ($relationship instanceof BelongsToMany) {
102 | // Retrieve related model instances and extract their IDs into an array.
103 | $state = $relationship->getResults()
104 | ->pluck($relationship->getRelatedKeyName())
105 | ->toArray();
106 |
107 | // Set the component's state with the extracted IDs.
108 | $component->state($state);
109 | }
110 | });
111 |
112 | // Save relationships using a callback function.
113 | $this->saveRelationshipsUsing(static function (self $component, $state) {
114 | // Check if the component's relationship is a BelongsToMany relationship.
115 | if ($component->getRelationship() instanceof BelongsToMany) {
116 | // Wrap the state in a collection and convert it to an array if it's not set.
117 | $state = Arr::wrap($state ?? []);
118 |
119 | $pivotData = $component->getPivotData();
120 |
121 | // Sync the relationship with the provided state (IDs).
122 | if ($pivotData === []) {
123 | $component->getRelationship()->sync($state ?? []);
124 |
125 | return;
126 | }
127 |
128 | // Sync the relationship with the provided state (IDs) plus pivot data.
129 | $component->getRelationship()->syncWithPivotValues($state ?? [], $pivotData);
130 | }
131 | });
132 |
133 | $this->createOptionUsing(static function (SelectTree $component, array $data, Form $form) {
134 | $record = $component->getRelationship()->getRelated();
135 | $record->fill($data);
136 | $record->save();
137 |
138 | $form->model($record)->saveRelationships();
139 |
140 | return $component->getCustomKey($record);
141 | });
142 |
143 | $this->dehydrated(fn (SelectTree $component): bool => ! $component->getRelationship() instanceof BelongsToMany);
144 |
145 | $this->placeholder(static fn (SelectTree $component): ?string => $component->isDisabled() ? null : __('filament-forms::components.select.placeholder'));
146 |
147 | $this->suffixActions([
148 | static fn (SelectTree $component): ?Action => $component->getCreateOptionAction(),
149 | ]);
150 |
151 | $this->treeKey('treeKey-'.rand());
152 | }
153 |
154 | protected function buildTree(): Collection
155 | {
156 | // Start with two separate query builders
157 | $nullParentQuery = $this->getRelationship()->getRelated()->query()->where($this->getParentAttribute(), $this->getParentNullValue());
158 | $nonNullParentQuery = $this->getRelationship()->getRelated()->query()->whereNot($this->getParentAttribute(), $this->getParentNullValue());
159 |
160 | // If we're not at the root level and a modification callback is provided, apply it to null query
161 | if ($this->modifyQueryUsing) {
162 | $nullParentQuery = $this->evaluate($this->modifyQueryUsing, ['query' => $nullParentQuery]);
163 | }
164 |
165 | // If we're at the child level and a modification callback is provided, apply it to non null query
166 | if ($this->modifyChildQueryUsing) {
167 | $nonNullParentQuery = $this->evaluate($this->modifyChildQueryUsing, ['query' => $nonNullParentQuery]);
168 | }
169 |
170 | if ($this->withTrashed) {
171 | $nullParentQuery->withTrashed($this->withTrashed);
172 | $nonNullParentQuery->withTrashed($this->withTrashed);
173 | }
174 |
175 | $nullParentResults = $nullParentQuery->get();
176 | $nonNullParentResults = $nonNullParentQuery->get();
177 |
178 | // Combine the results from both queries
179 | $combinedResults = $nullParentResults->concat($nonNullParentResults);
180 |
181 | // Store results for additional functionality
182 | if ($this->storeResults) {
183 | $this->results = $combinedResults;
184 | }
185 |
186 | return $this->buildTreeFromResults($combinedResults);
187 | }
188 |
189 | private function buildTreeFromResults($results, $parent = null): Collection
190 | {
191 | // Assign the parent's null value to the $parent variable if it's not null
192 | if ($parent == null || $parent == $this->getParentNullValue()) {
193 | $parent = $this->getParentNullValue() ?? $parent;
194 | }
195 |
196 | // Create a collection to store the tree
197 | $tree = collect();
198 |
199 | // Create a mapping of results by their parent IDs for faster lookup
200 | $resultMap = [];
201 |
202 | // Group results by their parent IDs
203 | foreach ($results as $result) {
204 | $parentId = $result->{$this->getParentAttribute()};
205 | if (! isset($resultMap[$parentId])) {
206 | $resultMap[$parentId] = [];
207 | }
208 | $resultMap[$parentId][] = $result;
209 | }
210 |
211 | // Define disabled options
212 | $disabledOptions = $this->getDisabledOptions();
213 |
214 | // Define hidden options
215 | $hiddenOptions = $this->getHiddenOptions();
216 |
217 | // Recursively build the tree starting from the root (null parent)
218 | $rootResults = $resultMap[$parent] ?? [];
219 | foreach ($rootResults as $result) {
220 | // Build a node and add it to the tree
221 | $node = $this->buildNode($result, $resultMap, $disabledOptions, $hiddenOptions);
222 | $tree->push($node);
223 | }
224 |
225 | return $tree;
226 | }
227 |
228 | private function buildNode($result, $resultMap, $disabledOptions, $hiddenOptions): array
229 | {
230 | $key = $this->getCustomKey($result);
231 |
232 | // Create a node with 'name' and 'value' attributes
233 | $node = [
234 | 'name' => $result->{$this->getTitleAttribute()},
235 | 'value' => $key,
236 | 'parent' => (string) $result->{$this->getParentAttribute()},
237 | 'disabled' => in_array($key, $disabledOptions),
238 | 'hidden' => in_array($key, $hiddenOptions),
239 | ];
240 |
241 | // Check if the result has children
242 | if (isset($resultMap[$key])) {
243 | $children = collect();
244 | // Recursively build child nodes
245 | foreach ($resultMap[$key] as $child) {
246 | // don't add the hidden ones
247 | if (in_array($this->getCustomKey($child), $hiddenOptions)) {
248 | continue;
249 | }
250 | $childNode = $this->buildNode($child, $resultMap, $disabledOptions, $hiddenOptions);
251 | $children->push($childNode);
252 | }
253 | // Add children to the node
254 | $node['children'] = $children->toArray();
255 | }
256 |
257 | return $node;
258 | }
259 |
260 | public function relationship(string $relationship, string $titleAttribute, string $parentAttribute, ?Closure $modifyQueryUsing = null, ?Closure $modifyChildQueryUsing = null): self
261 | {
262 | $this->relationship = $relationship;
263 | $this->titleAttribute = $titleAttribute;
264 | $this->parentAttribute = $parentAttribute;
265 | $this->modifyQueryUsing = $modifyQueryUsing;
266 | $this->modifyChildQueryUsing = $modifyChildQueryUsing;
267 |
268 | return $this;
269 | }
270 |
271 | public function withCount(bool $withCount = true): static
272 | {
273 | $this->withCount = $withCount;
274 |
275 | return $this;
276 | }
277 |
278 | public function withTrashed(bool $withTrashed = true): static
279 | {
280 | $this->withTrashed = $withTrashed;
281 |
282 | return $this;
283 | }
284 |
285 | public function direction(string $direction): static
286 | {
287 | $this->direction = $direction;
288 |
289 | return $this;
290 | }
291 |
292 | public function parentNullValue(int|string|null $parentNullValue = null): static
293 | {
294 | $this->parentNullValue = $parentNullValue;
295 |
296 | return $this;
297 | }
298 |
299 | public function multiple(Closure|bool $multiple = true): static
300 | {
301 | $this->multiple = $multiple;
302 |
303 | return $this;
304 | }
305 |
306 | public function prepend(Closure|array|null $prepend = null): static
307 | {
308 | $this->prepend = $this->evaluate($prepend);
309 |
310 | if (is_array($this->prepend) && isset($this->prepend['name'], $this->prepend['value'])) {
311 | $this->prepend['value'] = (string) $this->prepend['value'];
312 | } else {
313 | throw new \InvalidArgumentException('The provided prepend value must be an array with "name" and "value" keys.');
314 | }
315 |
316 | return $this;
317 | }
318 |
319 | public function getRelationship(): BelongsToMany|BelongsTo
320 | {
321 | return $this->getModelInstance()->{$this->evaluate($this->relationship)}();
322 | }
323 |
324 | public function getTitleAttribute(): string
325 | {
326 | return $this->evaluate($this->titleAttribute);
327 | }
328 |
329 | public function getParentAttribute(): string
330 | {
331 | return $this->evaluate($this->parentAttribute);
332 | }
333 |
334 | public function getParentNullValue(): null|int|string
335 | {
336 | return $this->evaluate($this->parentNullValue);
337 | }
338 |
339 | public function clearable(bool $clearable = true): static
340 | {
341 | $this->clearable = $clearable;
342 |
343 | return $this;
344 | }
345 |
346 | public function grouped(bool $grouped = true): static
347 | {
348 | $this->grouped = $grouped;
349 |
350 | return $this;
351 | }
352 |
353 | public function defaultOpenLevel(Closure|int $defaultOpenLevel = 0): static
354 | {
355 | $this->defaultOpenLevel = $defaultOpenLevel;
356 |
357 | return $this;
358 | }
359 |
360 | public function expandSelected(bool $expandSelected = true): static
361 | {
362 | $this->expandSelected = $expandSelected;
363 |
364 | return $this;
365 | }
366 |
367 | public function emptyLabel(string $emptyLabel): static
368 | {
369 | $this->noSearchResultsMessage($emptyLabel);
370 |
371 | return $this;
372 | }
373 |
374 | public function independent(bool $independent = true): static
375 | {
376 | $this->independent = $independent;
377 |
378 | return $this;
379 | }
380 |
381 | public function withKey(string $customKey): static
382 | {
383 | $this->customKey = $customKey;
384 |
385 | return $this;
386 | }
387 |
388 | public function disabledOptions(Closure|array $disabledOptions): static
389 | {
390 | $this->disabledOptions = $disabledOptions;
391 |
392 | return $this;
393 | }
394 |
395 | public function hiddenOptions(Closure|array $hiddenOptions): static
396 | {
397 | $this->hiddenOptions = $hiddenOptions;
398 |
399 | return $this;
400 | }
401 |
402 | public function alwaysOpen(bool $alwaysOpen = true): static
403 | {
404 | $this->alwaysOpen = $alwaysOpen;
405 |
406 | return $this;
407 | }
408 |
409 | public function enableBranchNode(bool $enableBranchNode = true): static
410 | {
411 | $this->enableBranchNode = $enableBranchNode;
412 |
413 | return $this;
414 | }
415 |
416 | public function storeResults(bool $storeResults = true): static
417 | {
418 | $this->storeResults = $storeResults;
419 |
420 | return $this;
421 | }
422 |
423 | public function getTree(): Collection|array
424 | {
425 | return $this->evaluate($this->buildTree()->when($this->prepend,
426 | fn (Collection $tree) => $tree->prepend($this->evaluate($this->prepend))));
427 | }
428 |
429 | public function getResults(): Collection|array|null
430 | {
431 | return $this->evaluate($this->results);
432 | }
433 |
434 | public function getExpandSelected(): bool
435 | {
436 | return $this->evaluate($this->expandSelected);
437 | }
438 |
439 | public function getGrouped(): bool
440 | {
441 | return $this->evaluate($this->grouped);
442 | }
443 |
444 | public function getWithTrashed(): bool
445 | {
446 | return $this->evaluate($this->withTrashed);
447 | }
448 |
449 | public function getIndependent(): bool
450 | {
451 | return $this->evaluate($this->independent);
452 | }
453 |
454 | public function getCustomKey($record): string
455 | {
456 | $key = is_null($this->customKey) ? $record->getKey() : $record->{$this->customKey};
457 |
458 | return (string) $key;
459 | }
460 |
461 | public function getWithCount(): bool
462 | {
463 | return $this->evaluate($this->withCount);
464 | }
465 |
466 | public function getMultiple(): bool
467 | {
468 | return $this->evaluate(
469 | is_null($this->multiple) ? $this->getRelationship() instanceof BelongsToMany : $this->evaluate($this->multiple)
470 | );
471 | }
472 |
473 | public function getClearable(): bool
474 | {
475 | return $this->evaluate($this->clearable);
476 | }
477 |
478 | public function getAlwaysOpen(): bool
479 | {
480 | return $this->evaluate($this->alwaysOpen);
481 | }
482 |
483 | public function getEnableBranchNode(): bool
484 | {
485 | return $this->evaluate($this->enableBranchNode);
486 | }
487 |
488 | public function getDefaultOpenLevel(): int
489 | {
490 | return $this->evaluate($this->defaultOpenLevel);
491 | }
492 |
493 | public function getEmptyLabel(): string
494 | {
495 | return $this->getNoSearchResultsMessage();
496 | }
497 |
498 | public function getDirection(): string
499 | {
500 | return $this->evaluate($this->direction);
501 | }
502 |
503 | public function getDisabledOptions(): array
504 | {
505 | return $this->evaluate($this->disabledOptions);
506 | }
507 |
508 | public function getHiddenOptions(): array
509 | {
510 | return $this->evaluate($this->hiddenOptions);
511 | }
512 |
513 | public function getCreateOptionActionForm(Form $form): array|Form|null
514 | {
515 | return $this->evaluate($this->createOptionActionForm, ['form' => $form]);
516 | }
517 |
518 | public function hasCreateOptionActionFormSchema(): bool
519 | {
520 | return (bool) $this->createOptionActionForm;
521 | }
522 |
523 | public function getCreateOptionModalHeading(): ?string
524 | {
525 | return $this->evaluate($this->createOptionModalHeading);
526 | }
527 |
528 | public function createOptionForm(array|Closure|null $schema): static
529 | {
530 | $this->createOptionActionForm = $schema;
531 |
532 | return $this;
533 | }
534 |
535 | public function getCreateOptionActionName(): string
536 | {
537 | return 'createOption';
538 | }
539 |
540 | public function getCreateOptionUsing(): ?Closure
541 | {
542 | return $this->createOptionUsing;
543 | }
544 |
545 | public function createOptionUsing(Closure $callback): static
546 | {
547 | $this->createOptionUsing = $callback;
548 |
549 | return $this;
550 | }
551 |
552 | public function getCreateOptionAction(): ?Action
553 | {
554 | if ($this->isDisabled()) {
555 | return null;
556 | }
557 |
558 | if (! $this->hasCreateOptionActionFormSchema()) {
559 | return null;
560 | }
561 |
562 | $action = Action::make($this->getCreateOptionActionName())
563 | ->form(function (SelectTree $component, Form $form): array|Form|null {
564 | return $component->getCreateOptionActionForm($form->model(
565 | $component->getRelationship() ? $component->getRelationship()->getModel()::class : null,
566 | ));
567 | })
568 | ->action(static function (Action $action, array $arguments, SelectTree $component, array $data, ComponentContainer $form) {
569 | if (! $component->getCreateOptionUsing()) {
570 | throw new Exception("Select field [{$component->getStatePath()}] must have a [createOptionUsing()] closure set.");
571 | }
572 |
573 | $createdOptionKey = $component->evaluate($component->getCreateOptionUsing(), [
574 | 'data' => $data,
575 | 'form' => $form,
576 | ]);
577 |
578 | $state = $component->getMultiple()
579 | ? [
580 | ...$component->getState() ?? [],
581 | $createdOptionKey,
582 | ]
583 | : $createdOptionKey;
584 |
585 | $component->state($state);
586 | $component->callAfterStateUpdated();
587 |
588 | if (! ($arguments['another'] ?? false)) {
589 | return;
590 | }
591 |
592 | $action->callAfter();
593 |
594 | $form->fill();
595 |
596 | $action->halt();
597 | })
598 | ->color('gray')
599 | ->icon(FilamentIcon::resolve('forms::components.select.actions.create-option') ?? 'heroicon-m-plus')
600 | ->iconButton()
601 | ->modalHeading($this->getCreateOptionModalHeading() ?? __('filament-forms::components.select.actions.create_option.modal.heading'))
602 | ->modalSubmitActionLabel(__('filament-forms::components.select.actions.create_option.modal.actions.create.label'))
603 | ->extraModalFooterActions(fn (Action $action, SelectTree $component): array => $component->getMultiple() ? [
604 | $action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
605 | ->label(__('filament-forms::components.select.actions.create_option.modal.actions.create_another.label')),
606 | ] : []);
607 |
608 | if ($this->modifyManageOptionActionsUsing) {
609 | $action = $this->evaluate($this->modifyManageOptionActionsUsing, [
610 | 'action' => $action,
611 | ]) ?? $action;
612 | }
613 |
614 | if ($this->modifyCreateOptionActionUsing) {
615 | $action = $this->evaluate($this->modifyCreateOptionActionUsing, [
616 | 'action' => $action,
617 | ]) ?? $action;
618 | }
619 |
620 | return $action;
621 | }
622 |
623 | public function createOptionModalHeading(string|Closure|null $heading): static
624 | {
625 | $this->createOptionModalHeading = $heading;
626 |
627 | return $this;
628 | }
629 |
630 | public function treeKey(string $treeKey): static
631 | {
632 | $this->treeKey = $treeKey;
633 |
634 | return $this;
635 | }
636 |
637 | public function getTreeKey(): string
638 | {
639 | return $this->evaluate($this->treeKey);
640 | }
641 | }
642 |
--------------------------------------------------------------------------------
/src/Testing/TestsFilamentSelectTree.php:
--------------------------------------------------------------------------------
1 |