From fe5a2789a7f31a97bd3c00c5e4ec3275e7140b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9ia=20Bohner?= Date: Thu, 29 Oct 2026 22:04:46 -0300 Subject: [PATCH 1/2] Add configurable sorting --- README.md | 21 +++++ src/Enums/LibrarySort.php | 43 ++++++++++ src/FilamentLibraryPlugin.php | 105 ++++++++++++++++++++++- src/Resources/LibraryItemResource.php | 4 +- tests/Unit/LibrarySortTest.php | 117 ++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 src/Enums/LibrarySort.php create mode 100644 tests/Unit/LibrarySortTest.php diff --git a/README.md b/README.md index 08f6dff..bf2b76d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,27 @@ public function panel(Panel $panel): Panel When unset, navigation items remain visible (backward compatible). Direct library URLs are still authorized by your library item policies. +### 4. Listing order + +Library listings default to folders first, then files and links. Each group is alphabetical, including inside every folder. People can still click a column to choose a different order for that table. + +```php +use Tapp\FilamentLibrary\Enums\LibrarySort; +use Tapp\FilamentLibrary\FilamentLibraryPlugin; + +public function panel(Panel $panel): Panel +{ + return $panel + ->plugins([ + FilamentLibraryPlugin::make() + ->defaultSort(LibrarySort::FoldersThenName), + // ->defaultSort(LibrarySort::Name) + // ->defaultSort('created_at', 'desc') + // ->defaultSort(fn ($query, string $direction) => $query->orderBy('name', $direction)), + ]); +} +``` + ## Permissions System The plugin features a sophisticated permissions system inspired by Google Drive. diff --git a/src/Enums/LibrarySort.php b/src/Enums/LibrarySort.php new file mode 100644 index 0000000..47abdd2 --- /dev/null +++ b/src/Enums/LibrarySort.php @@ -0,0 +1,43 @@ + null, + self::Name => 'name', + self::CreatedAt => 'created_at', + self::UpdatedAt => 'updated_at', + }; + } + + /** + * @param Builder $query + * @return Builder + */ + public function apply(Builder $query, string $direction): Builder + { + $direction = $direction === 'desc' ? 'desc' : 'asc'; + + if ($this === self::FoldersThenName) { + return $query + ->orderByRaw('case when type = ? then 0 else 1 end', ['folder']) + ->orderBy('name', $direction); + } + + return $query->orderBy((string) $this->column(), $direction); + } +} diff --git a/src/FilamentLibraryPlugin.php b/src/FilamentLibraryPlugin.php index 5b6796f..15b21bf 100644 --- a/src/FilamentLibraryPlugin.php +++ b/src/FilamentLibraryPlugin.php @@ -5,9 +5,14 @@ use App\Models\User; use Closure; use Filament\Contracts\Plugin; +use Filament\Facades\Filament; use Filament\Navigation\NavigationItem; use Filament\Panel; +use Filament\Tables\Table; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Builder; +use InvalidArgumentException; +use Tapp\FilamentLibrary\Enums\LibrarySort; use Tapp\FilamentLibrary\Models\LibraryItem; use Tapp\FilamentLibrary\Models\LibraryItemPermission; use Tapp\FilamentLibrary\Models\LibraryItemTag; @@ -24,6 +29,10 @@ class FilamentLibraryPlugin implements Plugin */ protected Closure | bool | null $navigationVisibleUsing = null; + protected LibrarySort | Closure | string $defaultSort = LibrarySort::FoldersThenName; + + protected string $defaultSortDirection = 'asc'; + /** * @var array */ @@ -52,6 +61,100 @@ public function navigationVisibleUsing(Closure | bool $condition): static return $this; } + /** + * Default order for every library listing. + * + * Folders come first, then files and links, and each group is alphabetical. + * Pass a column name (`name`, `created_at`, `updated_at`), a {@see LibrarySort} + * case, or a closure that receives the query and direction. + * + * People can still pick another column in the table. That choice replaces this default. + * + * @param (Closure(Builder, string): Builder)|LibrarySort|string $sort + */ + public function defaultSort(LibrarySort | Closure | string $sort, string $direction = 'asc'): static + { + if (is_string($sort) && trim($sort) === '') { + throw new InvalidArgumentException('Library sort column cannot be empty.'); + } + + $this->defaultSort = $sort; + $this->defaultSortDirection = $direction === 'desc' ? 'desc' : 'asc'; + + return $this; + } + + public function getDefaultSort(): LibrarySort | Closure | string + { + return $this->defaultSort; + } + + public function getDefaultSortDirection(): string + { + return $this->defaultSortDirection; + } + + /** + * @param Builder $query + * @return Builder + */ + public function applySort(Builder $query, ?string $direction = null): Builder + { + $direction = ($direction ?? $this->defaultSortDirection) === 'desc' ? 'desc' : 'asc'; + $sort = $this->defaultSort; + + if ($sort instanceof Closure) { + $sorted = $sort($query, $direction); + + return $sorted instanceof Builder ? $sorted : $query; + } + + if ($sort instanceof LibrarySort) { + return $sort->apply($query, $direction); + } + + return $query->orderBy($sort, $direction); + } + + public function configureTableSort(Table $table): Table + { + $sort = $this->defaultSort; + $direction = $this->defaultSortDirection; + + if (is_string($sort)) { + return $table->defaultSort($sort, $direction); + } + + if ($sort instanceof LibrarySort && $sort->column() !== null) { + return $table->defaultSort($sort->column(), $direction); + } + + $table = $table->defaultSort( + fn (Builder $query, string $direction): Builder => static::current()->applySort($query, $direction), + $direction, + ); + + if ($sort === LibrarySort::FoldersThenName) { + $table->defaultSortOptionLabel('Folders, then name'); + } + + return $table; + } + + public static function current(): static + { + $panel = Filament::getCurrentPanel(); + + if ($panel?->hasPlugin('filament-library')) { + /** @var static $plugin */ + $plugin = $panel->getPlugin('filament-library'); + + return $plugin; + } + + return new static; + } + /** * Set a custom callback to determine if a user is a library admin. * @@ -117,7 +220,7 @@ public static function modelClass(string $model): string /** @var class-string $modelClass */ $modelClass = config( "filament-library.models.{$model}", - static::$defaultModels[$model] ?? throw new \InvalidArgumentException("Unknown filament-library model [{$model}]."), + static::$defaultModels[$model] ?? throw new InvalidArgumentException("Unknown filament-library model [{$model}]."), ); return $modelClass; diff --git a/src/Resources/LibraryItemResource.php b/src/Resources/LibraryItemResource.php index f1022c6..eae2a6a 100644 --- a/src/Resources/LibraryItemResource.php +++ b/src/Resources/LibraryItemResource.php @@ -169,7 +169,7 @@ public static function table(Table $table): Table // - CreatedByMe (user's created items) // - SearchAll (search results) // The list pages only override getTableQuery() for filtering, not the columns - return $table + return FilamentLibraryPlugin::current()->configureTableSort($table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() @@ -369,7 +369,7 @@ public static function table(Table $table): Table ? static::getUrl('index', ['parent' => $record->id]) : static::getUrl('view', ['record' => $record]); }); - }); + })); } public static function getRelations(): array diff --git a/tests/Unit/LibrarySortTest.php b/tests/Unit/LibrarySortTest.php new file mode 100644 index 0000000..4ff0cb6 --- /dev/null +++ b/tests/Unit/LibrarySortTest.php @@ -0,0 +1,117 @@ +insertGetId([ + 'name' => 'Librarian', + 'email' => 'librarian@example.com', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parent = librarySortItem($userId, 'Reference', 'folder'); + + librarySortItem($userId, 'Mango', 'folder', $parent->id); + librarySortItem($userId, 'Apples', 'file', $parent->id); + librarySortItem($userId, 'Zebra', 'folder', $parent->id); + librarySortItem($userId, 'Bananas', 'link', $parent->id); + librarySortItem($userId, 'Root file', 'file'); + + $plugin = FilamentLibraryPlugin::make(); + + expect($plugin->getDefaultSort())->toBe(LibrarySort::FoldersThenName) + ->and($plugin->getDefaultSortDirection())->toBe('asc'); + + $names = $plugin + ->applySort(LibraryItem::query()->where('parent_id', $parent->id)) + ->pluck('name') + ->all(); + + expect($names)->toBe(['Mango', 'Zebra', 'Apples', 'Bananas']); +}); + +test('folders stay first when the name direction is descending', function () { + $userId = DB::table('users')->insertGetId([ + 'name' => 'Librarian', + 'email' => 'librarian-desc@example.com', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parent = librarySortItem($userId, 'Reference', 'folder'); + librarySortItem($userId, 'Mango', 'folder', $parent->id); + librarySortItem($userId, 'Apples', 'file', $parent->id); + librarySortItem($userId, 'Zebra', 'folder', $parent->id); + librarySortItem($userId, 'Bananas', 'link', $parent->id); + + $names = FilamentLibraryPlugin::make() + ->defaultSort(LibrarySort::FoldersThenName, 'desc') + ->applySort(LibraryItem::query()->where('parent_id', $parent->id)) + ->pluck('name') + ->all(); + + expect($names)->toBe(['Zebra', 'Mango', 'Bananas', 'Apples']); +}); + +test('a column sort mixes folders and documents', function () { + $userId = DB::table('users')->insertGetId([ + 'name' => 'Librarian', + 'email' => 'librarian-name@example.com', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parent = librarySortItem($userId, 'Reference', 'folder'); + librarySortItem($userId, 'Mango', 'folder', $parent->id); + librarySortItem($userId, 'Apples', 'file', $parent->id); + librarySortItem($userId, 'Bananas', 'link', $parent->id); + + $names = FilamentLibraryPlugin::make() + ->defaultSort(LibrarySort::Name) + ->applySort(LibraryItem::query()->where('parent_id', $parent->id)) + ->pluck('name') + ->all(); + + expect($names)->toBe(['Apples', 'Bananas', 'Mango']); +}); + +test('a closure can replace the library sort', function () { + $userId = DB::table('users')->insertGetId([ + 'name' => 'Librarian', + 'email' => 'librarian-closure@example.com', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + librarySortItem($userId, 'Alpha', 'file'); + librarySortItem($userId, 'Zulu', 'folder'); + + $names = FilamentLibraryPlugin::make() + ->defaultSort(fn (Builder $query): Builder => $query->orderBy('name', 'desc')) + ->applySort(LibraryItem::query()) + ->pluck('name') + ->all(); + + expect($names)->toBe(['Zulu', 'Alpha']); +}); + +test('an empty library sort column is rejected', function () { + FilamentLibraryPlugin::make()->defaultSort(' '); +})->throws(InvalidArgumentException::class); + +function librarySortItem(int $userId, string $name, string $type, ?int $parentId = null): LibraryItem +{ + return LibraryItem::query()->create([ + 'name' => $name, + 'type' => $type, + 'parent_id' => $parentId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'general_access' => 'anyone_can_view', + ]); +} From 0b9aad9b307c8de0074768cf0a72478a3fdaaf0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9ia=20Bohner?= Date: Thu, 29 Oct 2026 22:31:30 -0300 Subject: [PATCH 2/2] Updates --- .github/workflows/run-tests.yml | 5 +++++ src/Enums/LibrarySort.php | 6 ++++-- src/FilamentLibraryPlugin.php | 11 ++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d626165..85a3fa7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -47,6 +47,11 @@ jobs: - name: Install dependencies run: | + # Laravel 11 is end of life, and Composer blocks every 11.x release + # for security advisories. This job still checks that constraint. + if [ "${{ matrix.laravel }}" = "11.*" ]; then + composer config policy.advisories.block false + fi composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction diff --git a/src/Enums/LibrarySort.php b/src/Enums/LibrarySort.php index 47abdd2..0eb876e 100644 --- a/src/Enums/LibrarySort.php +++ b/src/Enums/LibrarySort.php @@ -25,8 +25,10 @@ public function column(): ?string } /** - * @param Builder $query - * @return Builder + * @template TModel of Model + * + * @param Builder $query + * @return Builder */ public function apply(Builder $query, string $direction): Builder { diff --git a/src/FilamentLibraryPlugin.php b/src/FilamentLibraryPlugin.php index 15b21bf..2926a54 100644 --- a/src/FilamentLibraryPlugin.php +++ b/src/FilamentLibraryPlugin.php @@ -29,6 +29,9 @@ class FilamentLibraryPlugin implements Plugin */ protected Closure | bool | null $navigationVisibleUsing = null; + /** + * @var (Closure(Builder, string): Builder)|LibrarySort|string + */ protected LibrarySort | Closure | string $defaultSort = LibrarySort::FoldersThenName; protected string $defaultSortDirection = 'asc'; @@ -70,7 +73,7 @@ public function navigationVisibleUsing(Closure | bool $condition): static * * People can still pick another column in the table. That choice replaces this default. * - * @param (Closure(Builder, string): Builder)|LibrarySort|string $sort + * @param (Closure(Builder, string): Builder)|LibrarySort|string $sort */ public function defaultSort(LibrarySort | Closure | string $sort, string $direction = 'asc'): static { @@ -104,9 +107,7 @@ public function applySort(Builder $query, ?string $direction = null): Builder $sort = $this->defaultSort; if ($sort instanceof Closure) { - $sorted = $sort($query, $direction); - - return $sorted instanceof Builder ? $sorted : $query; + return $sort($query, $direction); } if ($sort instanceof LibrarySort) { @@ -152,7 +153,7 @@ public static function current(): static return $plugin; } - return new static; + return static::make(); } /**