diff --git a/README.md b/README.md index 52a4341..cc4ffcd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,13 @@ composer require awcodes/recently php artisan recently:install ``` +> [!IMPORTANT] +> **Upgrading an existing installation?** Publish and run the new migration so recent entries can reference their record (this powers automatic filtering of deleted records): +> ```bash +> php artisan vendor:publish --tag="recently-migrations" +> php artisan migrate +> ``` + > [!IMPORTANT] > If you have not set up a custom theme and are using Filament Panels follow the instructions in the [Filament Docs](https://filamentphp.com/docs/4.x/styling/overview#creating-a-custom-theme) first. @@ -94,6 +101,10 @@ class ViewUser extends ViewRecord } ``` +### Deleted Records + +Recent entries are automatically hidden from the menu and global search once the record they point to no longer exists — including soft-deleted records — so users never follow a stale link to a "Record not found" page. Each entry stores a polymorphic `recordable` reference to its record, and only entries whose record still resolves are displayed. Entries recorded for pages without an underlying record are always kept. + ## Configuration You can enable/disable or customize the plugin's features either globally through the `config` file or per panel. diff --git a/database/migrations/add_recordable_to_recent_entries_table.php.stub b/database/migrations/add_recordable_to_recent_entries_table.php.stub new file mode 100644 index 0000000..920102b --- /dev/null +++ b/database/migrations/add_recordable_to_recent_entries_table.php.stub @@ -0,0 +1,15 @@ +nullableMorphs('recordable'); + }); + } +}; diff --git a/src/Concerns/HasRecentHistoryRecorder.php b/src/Concerns/HasRecentHistoryRecorder.php index 1d8bcc5..dd0af99 100644 --- a/src/Concerns/HasRecentHistoryRecorder.php +++ b/src/Concerns/HasRecentHistoryRecorder.php @@ -40,6 +40,7 @@ protected function recordHistory(): void url: request()->url(), icon: $resource::getNavigationIcon(), title: strip_tags((string) $title), + record: $record, ); } diff --git a/src/Facades/Recently.php b/src/Facades/Recently.php index ff92d5d..7d830fa 100644 --- a/src/Facades/Recently.php +++ b/src/Facades/Recently.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Facade; /** - * @method static void add(string $url, string $icon, string $title) + * @method static void add(string $url, string|\BackedEnum|null $icon, string $title, ?\Illuminate\Database\Eloquent\Model $record = null) * * @see \Awcodes\Recently\Recently */ diff --git a/src/Livewire/RecentlyMenu.php b/src/Livewire/RecentlyMenu.php index ba60f82..9cf5709 100644 --- a/src/Livewire/RecentlyMenu.php +++ b/src/Livewire/RecentlyMenu.php @@ -51,6 +51,7 @@ public function getRecords(): void $model = config('recently.model'); $this->records = $model::query() + ->existing() ->orderByDesc('updated_at') ->limit($this->maxItems) ->get(); diff --git a/src/Models/RecentEntry.php b/src/Models/RecentEntry.php index efa3ea4..56e9a4d 100644 --- a/src/Models/RecentEntry.php +++ b/src/Models/RecentEntry.php @@ -8,9 +8,11 @@ use Awcodes\Recently\Models\Scopes\RecentEntryScope; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\ScopedBy; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property-read int $id @@ -18,6 +20,8 @@ * @property string $url * @property string|null $icon * @property string|null $title + * @property string|null $recordable_type + * @property int|string|null $recordable_id * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at */ @@ -31,6 +35,8 @@ class RecentEntry extends Model 'url', 'icon', 'title', + 'recordable_type', + 'recordable_id', ]; public function user(): BelongsTo @@ -38,6 +44,26 @@ public function user(): BelongsTo return $this->belongsTo(config('recently.user_model')); } + public function recordable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Limit to entries that still point at a live record — i.e. those with no + * record reference (e.g. non-record pages), or whose referenced record + * still exists. A soft-deleted or hard-deleted record fails the morph + * existence check and is excluded. + */ + public function scopeExisting(Builder $query): void + { + $query->where(function (Builder $query): void { + $query + ->whereNull('recordable_type') + ->orWhereHasMorph('recordable', '*'); + }); + } + protected static function newFactory(): RecentEntryFactory { return new RecentEntryFactory; diff --git a/src/Recently.php b/src/Recently.php index 908218d..d45a77a 100644 --- a/src/Recently.php +++ b/src/Recently.php @@ -11,7 +11,7 @@ class Recently { - public function add(string $url, string|BackedEnum|null $icon, string $title): void + public function add(string $url, string|BackedEnum|null $icon, string $title, ?Model $record = null): void { if ($icon instanceof Heroicon) { $icon = "heroicon-$icon->value"; @@ -28,6 +28,8 @@ public function add(string $url, string|BackedEnum|null $icon, string $title): v 'url' => $url, 'icon' => $icon ?? '', 'title' => $title, + 'recordable_type' => $record?->getMorphClass(), + 'recordable_id' => $record?->getKey(), ]); } } diff --git a/src/RecentlyServiceProvider.php b/src/RecentlyServiceProvider.php index 935b354..ecaf2a6 100644 --- a/src/RecentlyServiceProvider.php +++ b/src/RecentlyServiceProvider.php @@ -44,6 +44,7 @@ protected function getMigrations(): array { return [ 'create_recently_table', + 'add_recordable_to_recent_entries_table', ]; } } diff --git a/src/Resources/RecentEntryResource.php b/src/Resources/RecentEntryResource.php index d465cf5..f8f5718 100644 --- a/src/Resources/RecentEntryResource.php +++ b/src/Resources/RecentEntryResource.php @@ -6,6 +6,7 @@ use Awcodes\Recently\RecentlyPlugin; use Filament\Resources\Resource; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; class RecentEntryResource extends Resource @@ -21,6 +22,11 @@ public static function getModel(): string return config('recently.model'); } + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->existing(); + } + public static function getGlobalSearchResultUrl(Model $record): ?string { return $record->url; diff --git a/tests/database/migrations/create_pages_table.php b/tests/database/migrations/create_pages_table.php index 2fdeaa4..01bd407 100644 --- a/tests/database/migrations/create_pages_table.php +++ b/tests/database/migrations/create_pages_table.php @@ -18,6 +18,7 @@ public function up(): void $table->longText('content')->nullable(); $table->timestamps(); + $table->softDeletes(); }); } }; diff --git a/tests/database/migrations/create_recent_entries_table.php b/tests/database/migrations/create_recent_entries_table.php index 3f8700e..d146127 100644 --- a/tests/database/migrations/create_recent_entries_table.php +++ b/tests/database/migrations/create_recent_entries_table.php @@ -17,6 +17,7 @@ public function up() $table->text('url'); $table->string('icon'); $table->string('title'); + $table->nullableMorphs('recordable'); $table->timestamps(); }); diff --git a/tests/src/DeletedRecordsTest.php b/tests/src/DeletedRecordsTest.php new file mode 100644 index 0000000..c8aa87d --- /dev/null +++ b/tests/src/DeletedRecordsTest.php @@ -0,0 +1,67 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + $this->panel = Filament::getCurrentOrDefaultPanel(); + $this->panel->plugins([RecentlyPlugin::make()]); + + $this->plugin = Filament::getPlugin('awcodes/recently'); +}); + +it('stores a polymorphic reference to the viewed record', function () { + $page = Page::factory()->create(); + $url = PageResource::getUrl('edit', ['record' => $page]); + + $this->get($url)->assertSuccessful(); + + $this->assertDatabaseHas(RecentEntry::class, [ + 'url' => $url, + 'recordable_type' => $page->getMorphClass(), + 'recordable_id' => $page->getKey(), + ]); +}); + +it('hides entries whose record has been deleted, keeping live and reference-less ones', function () { + $page = Page::factory()->create(); + $url = PageResource::getUrl('edit', ['record' => $page]); + $this->get($url)->assertSuccessful(); + + // an entry that points at nothing (no record reference) must always survive + RecentEntry::create([ + 'user_id' => $this->user->id, + 'url' => 'https://example.test/legacy', + 'icon' => '', + 'title' => 'Legacy', + ]); + + expect(RecentEntry::existing()->pluck('url')->all())->toContain($url); + + $page->delete(); + + expect(RecentEntry::existing()->pluck('url')->all()) + ->not->toContain($url) + ->toContain('https://example.test/legacy'); + + // menu surface + $records = livewire(RecentlyMenu::class)->instance()->records; + expect($records->pluck('url')->all())->not->toContain($url); + + // global-search surface + $searchUrls = RecentEntryResource::getGlobalSearchEloquentQuery()->pluck('url')->all(); + expect($searchUrls)->not->toContain($url); +}); diff --git a/tests/src/Models/Page.php b/tests/src/Models/Page.php index 5dbb437..75cb7da 100644 --- a/tests/src/Models/Page.php +++ b/tests/src/Models/Page.php @@ -7,10 +7,12 @@ use Awcodes\Recently\Tests\Database\Factories\PageFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class Page extends Model { use HasFactory; + use SoftDeletes; protected $guarded = [];