From d24afbcd097fa9f7dc6d0203557a57dad9d979e1 Mon Sep 17 00:00:00 2001 From: grafst Date: Sat, 8 Aug 2026 13:17:25 +0200 Subject: [PATCH 1/3] Render links via table header hook so existing descriptions are kept --- src/QuickLinksServiceProvider.php | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/QuickLinksServiceProvider.php b/src/QuickLinksServiceProvider.php index aa4b9e9..79468ff 100644 --- a/src/QuickLinksServiceProvider.php +++ b/src/QuickLinksServiceProvider.php @@ -2,7 +2,10 @@ namespace Niladam\QuickLinks; -use Filament\Tables\Table; +use Filament\Support\Facades\FilamentView; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\View\TablesRenderHook; +use Livewire\Livewire; use Niladam\QuickLinks\Facades\QuickLinks; use Spatie\LaravelPackageTools\Commands\InstallCommand; use Spatie\LaravelPackageTools\Package; @@ -39,10 +42,29 @@ public function packageRegistered(): void {} public function packageBooted(): void { - if (QuickLinks::isDisabled()) { - return; - } + // Rendered below the table header instead of via ->description(), so + // tables that define their own description keep it — the quick links + // are appended rather than silently dropped. The enabled check runs + // per render, so runtime config changes are respected. + FilamentView::registerRenderHook( + TablesRenderHook::HEADER_AFTER, + static function (): ?string { + if (QuickLinks::isDisabled()) { + return null; + } + + $component = Livewire::current(); + + if (! $component instanceof HasTable) { + return null; + } + + $links = QuickLinks::build($component->getTable()); - Table::configureUsing(static fn (Table $table) => $table->description(fn () => QuickLinks::build($table))); + return $links + ? '' + : null; + }, + ); } } From 64ec2479b4df0a1b6e421e5978dd72bf347b7864 Mon Sep 17 00:00:00 2001 From: Madalin Tache Date: Sat, 8 Aug 2026 16:13:41 +0300 Subject: [PATCH 2/3] Render quick links from a Blade view and support Filament v3, v4 and v5 The render hook keeps a table's own description, which was the point of the change, but it built its markup as an HTML string inside the service provider. That markup now lives in a publishable Blade view, and the links reach it as structured data rather than pre-rendered anchors. Alongside that: - Require filament/tables ^3.0|^4.0|^5.0. The package has always imported Filament\Tables but never declared the dependency. TablesRenderHook and registerRenderHook() are identical across all three majors, so one code path covers them. - Raise the PHP floor to ^8.2, matching what Filament v4 and v5 require. 8.1 went end of life in December 2025 and cannot be tested with a current Pest. - Fix disableIf(). It stored the result of its closure instead of the closure, so it ran once while the provider booted - before auth() has a user to ask about - and the result was applied inverted, enabling where it should have disabled. It is now evaluated per render, which the render hook allows. - Add a Pest suite and a CI matrix covering PHP 8.2, 8.3 and 8.4 against Filament v3, v4 and v5. --- .github/workflows/run-tests.yml | 40 +++++++ README.md | 24 +++- composer.json | 22 +++- phpunit.xml.dist | 17 +++ resources/views/quick-links.blade.php | 22 ++++ src/QuickLinks.php | 96 +++++++++------- src/QuickLinksServiceProvider.php | 30 +++-- tests/Fixtures/TestComponent.php | 56 ++++++++++ tests/Fixtures/TestModel.php | 10 ++ tests/Fixtures/TestResource.php | 15 +++ tests/Pest.php | 5 + tests/QuickLinksTest.php | 154 ++++++++++++++++++++++++++ tests/TestCase.php | 46 ++++++++ 13 files changed, 475 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/run-tests.yml create mode 100644 phpunit.xml.dist create mode 100644 resources/views/quick-links.blade.php create mode 100644 tests/Fixtures/TestComponent.php create mode 100644 tests/Fixtures/TestModel.php create mode 100644 tests/Fixtures/TestResource.php create mode 100644 tests/Pest.php create mode 100644 tests/QuickLinksTest.php create mode 100644 tests/TestCase.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..53b1e88 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,40 @@ +name: "Run Tests" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + filament: ['3', '4', '5'] + + name: PHP ${{ matrix.php }} - Filament ${{ matrix.filament }} + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, dom, curl, libxml, mbstring, zip + coverage: none + + - name: Install dependencies + run: | + composer require "filament/tables:^${{ matrix.filament }}.0" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/pest diff --git a/README.md b/README.md index b9052a4..7dd640b 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,10 @@ Quickly open resource, models, and other files from within your FilamentPHP table in your PHPstorm editor. -> [!IMPORTANT] -> This package uses your table's `description` field (see [docs](https://filamentphp.com/docs/3.x/tables/advanced#customizing-the-table-header)), so if there's a description already the quick links won't appear. +> [!NOTE] +> The links are rendered through Filament's `tables::header.after` render hook, underneath the table header, so a table that already sets its own `description` keeps it. +> +> That spot lives inside the table's header container, which Filament hides when a table has nothing to put in it. A table with no heading, no description, no header actions, no search, no filters and no column manager therefore won't show the links. ## Installation @@ -78,6 +80,16 @@ return [ ]; ``` +## Styling + +The links are rendered from a Blade view. Publish it if you want to change the markup: + +```bash +php artisan vendor:publish --tag=quick-links-views +``` + +It lands in `resources/views/vendor/quick-links/quick-links.blade.php` and receives `$links` (each with a `url` and a `label`), `$prefix` and `$separator`. + ## Conditional disabling While you can disablee the package entirely by setting the `QUICK_LINKS_ENABLED` environment variable to `false` you can also use a closure to conditionally disable it. @@ -106,6 +118,14 @@ QuickLinks::disableOn(App\Filament\Resources\OrderResource::class); Simply add the FQCN(s) (fully qualified class name) to your resource in the `quick-links.disabled` config option. +## Testing + +```bash +composer test +``` + +The suite runs against Filament v3, v4 and v5. + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. diff --git a/composer.json b/composer.json index be19fc2..3e4d6da 100644 --- a/composer.json +++ b/composer.json @@ -24,22 +24,36 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", + "filament/tables": "^3.0|^4.0|^5.0", + "livewire/livewire": "^3.5|^4.0", "spatie/laravel-package-tools": "^1.15.0" }, "require-dev": { - "laravel/pint": "^1.0" + "laravel/pint": "^1.25", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0" }, "autoload": { "psr-4": { "Niladam\\QuickLinks\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Niladam\\QuickLinks\\Tests\\": "tests/" + } + }, "scripts": { - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "test": "vendor/bin/pest" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } }, "extra": { "laravel": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a282b72 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/resources/views/quick-links.blade.php b/resources/views/quick-links.blade.php new file mode 100644 index 0000000..671ee8e --- /dev/null +++ b/resources/views/quick-links.blade.php @@ -0,0 +1,22 @@ +{{-- + Rendered into TablesRenderHook::HEADER_AFTER, underneath the table's own + header, so a table that already defines a description keeps it. + + The `fi-*` classes are Filament's own. From v4 they carry the styling by + themselves, while v3 styled the markup with the utility classes sitting + alongside them, so both are present to cover every supported major. +--}} +
+ +
diff --git a/src/QuickLinks.php b/src/QuickLinks.php index e1af8fb..b897e56 100644 --- a/src/QuickLinks.php +++ b/src/QuickLinks.php @@ -4,7 +4,7 @@ use Closure; use Filament\Tables\Table; -use Illuminate\Support\HtmlString; +use Illuminate\Contracts\Support\Htmlable; use ReflectionClass; class QuickLinks @@ -26,16 +26,17 @@ public function resourceIsDisabled(string $resource): bool $disabledResources = config('quick-links.disabled', []); - if (in_array($resource, $disabledResources, true)) { - return true; - } - - return false; + return in_array($resource, $disabledResources, true); } + /** + * Store the closure rather than its result, so it is evaluated once per + * render. Calling it here would run it while the service provider boots, + * before there is a request to decide against - auth() has no user yet. + */ public function disableIf(Closure $closure): void { - static::$enabled = $closure(); + static::$enabled = static fn (): bool => ! $closure(); } /** @@ -59,19 +60,17 @@ public function link(string $filePath): array public function isEnabled(): bool { - if (static::$enabled instanceof Closure) { - return static::$enabled; - } - - if (static::$enabled === false) { + if (! config('quick-links.enabled', true)) { return false; } - if (! config('quick-links.enabled', true)) { - return false; + $enabled = static::$enabled; + + if ($enabled instanceof Closure) { + $enabled = $enabled(); } - return true; + return (bool) $enabled; } public function isDisabled(): bool @@ -79,6 +78,9 @@ public function isDisabled(): bool return ! $this->isEnabled(); } + /** + * @return array + */ public function buildResourceLinks(Table $table): array { $resourceLinks = config('quick-links.links', []); @@ -87,21 +89,26 @@ public function buildResourceLinks(Table $table): array return []; } + $livewire = $table->getLivewire(); + $availableLinks = array_filter([ - 'resource' => $table->getLivewire()->getResource(), - 'model' => $table->getLivewire()->getModel(), + 'resource' => $livewire->getResource(), + 'model' => $livewire->getModel(), 'env' => base_path('.env'), ], fn ($key) => $resourceLinks[$key] ?? false, ARRAY_FILTER_USE_KEY); $links = []; foreach ($availableLinks as $path) { - $links[] = $this->renderLink($path); + $links[] = $this->toLink($path); } return $links; } + /** + * @return array + */ public function buildFileLinks(): array { $files = config('quick-links.files', []); @@ -119,52 +126,61 @@ public function buildFileLinks(): array $links = []; foreach ($validFiles as $file => $title) { - $links[] = $this->renderLink($file, $title); + $links[] = $this->toLink($file, $title); } return $links; } - public function build(Table $table): ?HtmlString + /** + * The links for a table, or an empty array when they are switched off. + * + * @return array + */ + public function links(Table $table): array { if ($this->isDisabled()) { - return null; + return []; } - if (! method_exists($table->getLivewire(), 'getResource')) { - return null; + $livewire = $table->getLivewire(); + + if (! method_exists($livewire, 'getResource')) { + return []; } - if ($this->resourceIsDisabled($table->getLivewire()->getResource())) { - return null; + if ($this->resourceIsDisabled($livewire->getResource())) { + return []; } - $links = [ + return [ ...$this->buildResourceLinks($table), ...$this->buildFileLinks(), ]; + } + + public function build(Table $table): ?Htmlable + { + $links = $this->links($table); if (empty($links)) { return null; } - $prefix = config('quick-links.prefix', 'Open in PHPStorm:'); - $separator = config('quick-links.separator', ' • '); - $linksHtml = implode($separator, $links); - - return new HtmlString("{$prefix} {$linksHtml}"); + return view('quick-links::quick-links', [ + 'links' => $links, + 'prefix' => config('quick-links.prefix', 'Open in PHPStorm:'), + 'separator' => config('quick-links.separator', ' • '), + ]); } - protected function renderLink(string $filePath, ?string $linkTitle = null): string + /** + * @return array{url: string, label: string} + */ + protected function toLink(string $filePath, ?string $linkTitle = null): array { - ['link' => $url, 'title' => $title] = static::link($filePath); + ['link' => $url, 'title' => $title] = $this->link($filePath); - $title = is_null($linkTitle) ? $title : $linkTitle; - - return sprintf( - '%s', - $url, - $title - ); + return ['url' => $url, 'label' => $linkTitle ?? $title]; } } diff --git a/src/QuickLinksServiceProvider.php b/src/QuickLinksServiceProvider.php index 79468ff..c12316d 100644 --- a/src/QuickLinksServiceProvider.php +++ b/src/QuickLinksServiceProvider.php @@ -5,6 +5,7 @@ use Filament\Support\Facades\FilamentView; use Filament\Tables\Contracts\HasTable; use Filament\Tables\View\TablesRenderHook; +use Illuminate\Contracts\Support\Htmlable; use Livewire\Livewire; use Niladam\QuickLinks\Facades\QuickLinks; use Spatie\LaravelPackageTools\Commands\InstallCommand; @@ -25,6 +26,7 @@ public function configurePackage(Package $package): void * More info: https://github.com/spatie/laravel-package-tools */ $package->name(static::$name) + ->hasViews(static::$viewNamespace) ->hasInstallCommand(function (InstallCommand $command) { $command ->publishConfigFile() @@ -42,28 +44,24 @@ public function packageRegistered(): void {} public function packageBooted(): void { - // Rendered below the table header instead of via ->description(), so - // tables that define their own description keep it — the quick links - // are appended rather than silently dropped. The enabled check runs - // per render, so runtime config changes are respected. + /* + * Rendered underneath the table header rather than through + * ->description(), so a table that sets its own description keeps it + * instead of having it overwritten. + * + * The hook runs on every render, which is also what lets + * QuickLinks::disableIf() be evaluated per request. + */ FilamentView::registerRenderHook( TablesRenderHook::HEADER_AFTER, - static function (): ?string { - if (QuickLinks::isDisabled()) { - return null; - } - - $component = Livewire::current(); + static function (): ?Htmlable { + $livewire = Livewire::current(); - if (! $component instanceof HasTable) { + if (! $livewire instanceof HasTable) { return null; } - $links = QuickLinks::build($component->getTable()); - - return $links - ? '' - : null; + return QuickLinks::build($livewire->getTable()); }, ); } diff --git a/tests/Fixtures/TestComponent.php b/tests/Fixtures/TestComponent.php new file mode 100644 index 0000000..3f71a53 --- /dev/null +++ b/tests/Fixtures/TestComponent.php @@ -0,0 +1,56 @@ +columns([]); + } + + /** + * InteractsWithTable fills $this->table from a Livewire lifecycle hook that + * never fires for a plain instance, so build it on demand instead. + */ + public function getTable(): Table + { + if (! isset($this->table)) { + $this->table = $this->table(Table::make($this)); + } + + return $this->table; + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + public function render(): string + { + return '
'; + } +} diff --git a/tests/Fixtures/TestModel.php b/tests/Fixtures/TestModel.php new file mode 100644 index 0000000..d587c3f --- /dev/null +++ b/tests/Fixtures/TestModel.php @@ -0,0 +1,10 @@ +in(__DIR__); diff --git a/tests/QuickLinksTest.php b/tests/QuickLinksTest.php new file mode 100644 index 0000000..941f83f --- /dev/null +++ b/tests/QuickLinksTest.php @@ -0,0 +1,154 @@ +toBe('phpstorm://open?file=' . rawurlencode('/app/Models/User.php')) + ->and($link['title'])->toBe('User.php'); +}); + +it('resolves a class name to the file it lives in', function () { + expect(QuickLinks::link(TestResource::class)['title'])->toBe('TestResource.php'); +}); + +/** + * Read straight off the manager rather than through hasRenderHook(), which + * Filament only grew in v4. + */ +function registeredHeaderHook(): Closure +{ + $hooks = (fn () => $this->renderHooks)->call(FilamentView::getFacadeRoot()); + + return head(head($hooks[TablesRenderHook::HEADER_AFTER])); +} + +it('registers a render hook underneath the table header', function () { + $hooks = (fn () => $this->renderHooks)->call(FilamentView::getFacadeRoot()); + + expect($hooks)->toHaveKey(TablesRenderHook::HEADER_AFTER); +}); + +it('renders nothing when no table component is being rendered', function () { + expect(registeredHeaderHook()())->toBeNull(); +}); + +it('renders the links into the header hook while a table component renders', function () { + config()->set('quick-links.links', ['resource' => true, 'model' => false, 'env' => false]); + + // What Livewire::current() reads, so the hook sees a component the way it + // would mid-render. + HandleComponents::$componentStack[] = new TestComponent; + + try { + $html = FilamentView::renderHook(TablesRenderHook::HEADER_AFTER)->toHtml(); + } finally { + array_pop(HandleComponents::$componentStack); + } + + expect($html) + ->toContain('phpstorm://open?file=') + ->toContain('TestResource.php'); +}); + +it('renders the links through the blade view', function () { + config()->set('quick-links.links', ['resource' => true, 'model' => false, 'env' => false]); + + $html = QuickLinks::build(quickLinksTable())->toHtml(); + + expect($html) + ->toContain('fi-ql') + ->toContain('Open in PHPStorm:') + ->toContain('phpstorm://open?file=') + ->toContain('TestResource.php'); +}); + +it('only includes the link types enabled in config', function () { + config()->set('quick-links.links', ['resource' => true, 'model' => false, 'env' => false]); + + $links = QuickLinks::links(quickLinksTable()); + + expect($links)->toHaveCount(1) + ->and($links[0]['label'])->toBe('TestResource.php'); +}); + +it('renders nothing when disabled through config', function () { + config()->set('quick-links.enabled', false); + + expect(QuickLinks::build(quickLinksTable()))->toBeNull(); +}); + +it('renders nothing when every link type is switched off', function () { + config()->set('quick-links.links', []); + config()->set('quick-links.files', []); + + expect(QuickLinks::build(quickLinksTable()))->toBeNull(); +}); + +it('disables when the disableIf closure returns true', function () { + QuickLinks::disableIf(fn () => true); + + expect(QuickLinks::isDisabled())->toBeTrue(); +}); + +it('stays enabled when the disableIf closure returns false', function () { + QuickLinks::disableIf(fn () => false); + + expect(QuickLinks::isEnabled())->toBeTrue(); +}); + +it('evaluates the disableIf closure per check rather than once at registration', function () { + $calls = 0; + + QuickLinks::disableIf(function () use (&$calls) { + $calls++; + + return false; + }); + + expect($calls)->toBe(0); + + QuickLinks::isEnabled(); + QuickLinks::isEnabled(); + + expect($calls)->toBe(2); +}); + +it('skips a resource disabled through code', function () { + QuickLinks::disableOn(TestResource::class); + + expect(QuickLinks::build(quickLinksTable()))->toBeNull(); +}); + +it('skips a resource disabled through config', function () { + config()->set('quick-links.disabled', [TestResource::class]); + + expect(QuickLinks::build(quickLinksTable()))->toBeNull(); +}); + +it('ignores configured files that do not exist', function () { + config()->set('quick-links.files', ['/does/not/exist.php' => 'missing']); + + expect(QuickLinks::buildFileLinks())->toBe([]); +}); + +it('labels configured files with the configured title', function () { + config()->set('quick-links.files', [__FILE__ => 'this test']); + + $links = QuickLinks::buildFileLinks(); + + expect($links)->toHaveCount(1) + ->and($links[0]['label'])->toBe('this test'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..0531eac --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,46 @@ + Date: Sat, 8 Aug 2026 16:37:31 +0300 Subject: [PATCH 3/3] Document the v2.0 upgrade Adds UPGRADING.md covering the raised PHP floor, the new filament/tables requirement, the disableIf() behaviour change and the changed return types, and drops the leftover skeleton entry from the CHANGELOG. The CHANGELOG itself is generated on release by update-changelog.yml from the release body, so it is not edited by hand here. --- CHANGELOG.md | 4 ---- README.md | 2 ++ UPGRADING.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 UPGRADING.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9820fd2..daf0d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,3 @@ All notable changes to `quick-links` will be documented in this file. ## v1.0.0 - 2025-02-18 Initial release - -## 1.0.0 - 202X-XX-XX - -- initial release diff --git a/README.md b/README.md index 7dd640b..2aa090c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ The suite runs against Filament v3, v4 and v5. Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. +Upgrading from v1? See [UPGRADING](UPGRADING.md). + ## Contributing Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..fdfcf40 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,53 @@ +# Upgrading + +## From v1.x to v2.0 + +### Requirements + +- PHP 8.2 or higher (was 8.1). +- `filament/tables` `^3.0|^4.0|^5.0` is now a declared dependency. The package + always needed it, but never asked for it, so Composer may now pull it in or + refuse to resolve where it previously stayed quiet. + +### `disableIf()` behaves as documented + +It used to call the closure immediately and store the result, then apply that +result the wrong way round. Following the README: + +```php +QuickLinks::disableIf(fn () => auth()->id() === 1); +``` + +ran while the service provider booted, when `auth()->id()` is still `null`, and +disabled the package for everybody. The closure is now stored and evaluated on +every render, and a closure returning `true` disables the links. + +If you inverted your closure to work around the old behaviour, remove the +inversion. + +### Links no longer use the table description + +They render through Filament's `tables::header.after` render hook, so a table +that sets its own `description()` keeps it. + +One consequence: that hook sits inside the table's header container, which +Filament hides when a table has nothing else to show there. A table with no +heading, no description, no header actions, no search, no filters and no column +manager will not display the links. + +### Return types + +`build()` returns a `View` (typed `?Htmlable`) rather than `?HtmlString`, and +`buildResourceLinks()` and `buildFileLinks()` return arrays shaped +`['url' => ..., 'label' => ...]` instead of HTML strings. + +If you called these directly, render the view or read the array keys instead of +concatenating the old strings. + +### Customising the markup + +The markup now lives in a Blade view rather than being built in PHP: + +```bash +php artisan vendor:publish --tag=quick-links-views +```