Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions src/Enums/LibrarySort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLibrary\Enums;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

enum LibrarySort: string
{
case FoldersThenName = 'folders_then_name';
case Name = 'name';
case CreatedAt = 'created_at';
case UpdatedAt = 'updated_at';

public function column(): ?string
{
return match ($this) {
self::FoldersThenName => null,
self::Name => 'name',
self::CreatedAt => 'created_at',
self::UpdatedAt => 'updated_at',
};
}

/**
* @template TModel of Model
*
* @param Builder<TModel> $query
* @return Builder<TModel>
*/
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);
}
}
106 changes: 105 additions & 1 deletion src/FilamentLibraryPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +29,13 @@ class FilamentLibraryPlugin implements Plugin
*/
protected Closure | bool | null $navigationVisibleUsing = null;

/**
* @var (Closure(Builder<LibraryItem>, string): Builder<LibraryItem>)|LibrarySort|string
*/
protected LibrarySort | Closure | string $defaultSort = LibrarySort::FoldersThenName;

protected string $defaultSortDirection = 'asc';

/**
* @var array<string, class-string>
*/
Expand Down Expand Up @@ -52,6 +64,98 @@ 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<LibraryItem>, string): Builder<LibraryItem>)|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<LibraryItem> $query
* @return Builder<LibraryItem>
*/
public function applySort(Builder $query, ?string $direction = null): Builder
{
$direction = ($direction ?? $this->defaultSortDirection) === 'desc' ? 'desc' : 'asc';
$sort = $this->defaultSort;

if ($sort instanceof Closure) {
return $sort($query, $direction);
}

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 static::make();
}

/**
* Set a custom callback to determine if a user is a library admin.
*
Expand Down Expand Up @@ -117,7 +221,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;
Expand Down
4 changes: 2 additions & 2 deletions src/Resources/LibraryItemResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions tests/Unit/LibrarySortTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Tapp\FilamentLibrary\Enums\LibrarySort;
use Tapp\FilamentLibrary\FilamentLibraryPlugin;
use Tapp\FilamentLibrary\Models\LibraryItem;

test('library listings default to folders first then name', function () {
$userId = DB::table('users')->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',
]);
}
Loading