diff --git a/.github/CONTRIBUTING.MD b/.github/CONTRIBUTING.MD deleted file mode 100644 index 937d029..0000000 --- a/.github/CONTRIBUTING.MD +++ /dev/null @@ -1,55 +0,0 @@ -# Contributing - -Contributions are **welcome** and will be fully **credited**. - -Please read and understand the contribution guide before creating an issue or pull request. - -## Etiquette - -This project is open source, and as such, the maintainers give their free time to build and maintain the source code -held within. They make the code freely available in the hope that it will be of use to other developers. It would be -extremely unfair for them to suffer abuse or anger for their hard work. - -Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the -world that developers are civilized and selfless people. - -It's the duty of the maintainer to ensure that all submissions to the project are of sufficient -quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. - -## Viability - -When requesting or submitting new features, first consider whether it might be useful to others. Open -source projects are used by many developers, who may have entirely different needs to your own. Think about -whether or not your feature is likely to be used by other users of the project. - -## Procedure - -Before filing an issue: - -- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. -- Check to make sure your feature suggestion isn't already present within the project. -- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. -- Check the pull requests tab to ensure that the feature isn't already in progress. - -Before submitting a pull request: - -- Check the codebase to ensure that your feature doesn't already exist. -- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. - -## Requirements - -If the project maintainer has any additional requirements, you will find them listed here. - -- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). - -- **Add tests!** - Your patch won't be accepted if it doesn't have tests. - -- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. - -- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. - -- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. - -- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. - -**Happy coding**! \ No newline at end of file diff --git a/.github/workflows/run-duster.yml b/.github/workflows/run-duster.yml deleted file mode 100644 index 5f50234..0000000 --- a/.github/workflows/run-duster.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Run Duster - -on: - push: - branches: [ main ] - pull_request: - -jobs: - duster: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: "Duster" - uses: tighten/duster-action@v1 \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 78bbd55..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Tests - -on: ['push', 'pull_request'] - -jobs: - ci: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.2 - tools: composer:v2 - coverage: xdebug - - - name: Install Dependencies - run: composer install --no-interaction --prefer-dist --optimize-autoloader - - - name: Tests - run: ./vendor/bin/pest \ No newline at end of file diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..8ff63ab --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,26 @@ +name: Static Analysis + +on: + push: + pull_request: + +jobs: + phpstan: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v4 + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=512M -c phpstan.neon.dist diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..aae9e26 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + + name: PHP ${{ matrix.php }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v4 + + - name: Run tests + run: vendor/bin/pest --parallel + diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml deleted file mode 100644 index 7787ca1..0000000 --- a/.github/workflows/update-changelog.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Update Changelog" - -on: - release: - types: [released] - -jobs: - update: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - ref: main - - - name: Update Changelog - uses: stefanzweifel/changelog-updater-action@v1 - with: - latest-version: ${{ github.event.release.name }} - release-notes: ${{ github.event.release.body }} - - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v4 - with: - branch: main - commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 98406c6..0000000 --- a/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "semi": false, - "singleQuote": true, - "trailingComma": "all" -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e5cd3ef..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,7 +0,0 @@ -# Changelog - -All notable changes to `ResourceLock` will be documented in this file. - -## 1.0.0 - 202X-XX-XX - -- initial release diff --git a/LICENSE.md b/LICENSE.md index b196e73..e7eb87d 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) Kenepa +Copyright (c) Blendbyte Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 94aeb4e..90ccf70 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,59 @@ # Resource Lock - -filament-resource-lock-art - +[![Latest Version on Packagist](https://img.shields.io/packagist/v/blendbyte/filament-resource-lock.svg?style=flat-square)](https://packagist.org/packages/blendbyte/filament-resource-lock) +[![Tests](https://github.com/blendbyte/filament-resource-lock/actions/workflows/tests.yml/badge.svg)](https://github.com/blendbyte/filament-resource-lock/actions/workflows/tests.yml) +[![Static Analysis](https://github.com/blendbyte/filament-resource-lock/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/blendbyte/filament-resource-lock/actions/workflows/static-analysis.yml) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +Filament Resource Lock is a Filament plugin that adds resource locking functionality to your site. When a user begins editing a resource, it is automatically locked to prevent other users from editing it at the same time. The resource will be automatically unlocked after a set period of time, or when the user saves or discards their changes. -[![Latest Version on Packagist](https://img.shields.io/packagist/v/kenepa/resource-lock.svg?style=flat-square)](https://packagist.org/packages/kenepa/resource-lock) -[![Total Downloads](https://img.shields.io/packagist/dt/kenepa/resource-lock.svg?style=flat-square)](https://packagist.org/packages/kenepa/resource-lock) +> **Note:** This package is a fork of [kenepa/resource-lock](https://github.com/kenepa/resource-lock), updated for **Filament v5** compatibility. If you are currently using `kenepa/resource-lock`, see the [migration guide](#migrating-from-keneparesource-lock) below. -Filament Resource Lock is a Filament plugin that adds resource locking functionality to your site. When a -user begins editing a resource, Filament Resource Lock automatically locks the resource to prevent other users from -editing it at the same time. The resource will be automatically unlocked after a set period of time, or when the user -saves or discards their changes. +## Migrating from kenepa/resource-lock -filament-resource-lock-art +This fork introduces the following breaking changes: -## Installation +1. **Composer package** — replace `kenepa/resource-lock` with `blendbyte/filament-resource-lock` + +2. **PHP namespace** — find and replace `Kenepa\ResourceLock` with `Blendbyte\FilamentResourceLock` across your application + +3. **Config file** — the config was renamed from `resource-lock.php` to `filament-resource-lock.php`. Re-publish if you have a customised config: + ```bash + php artisan vendor:publish --tag="filament-resource-lock-config" --force + ``` -| Plugin Version | Filament Version | PHP Version | -|----------------|------------------|-------------| -| 1.x | 2.x | 8.0+ | -| 2.x | 3.x | 8.1+ | -| 3.x | 3.x | 8.1+ | -| 4.x | 4.x | 8.2+ | +4. **Artisan commands** — command signatures changed from `resource-lock:*` to `filament-resource-lock:*` (e.g. `filament-resource-lock:install`) -You can install the package via composer: +5. **Filament version** — this package requires **Filament v5**. The original `kenepa/resource-lock` targets Filament v3/v4. + +Quick steps: ```bash -composer require kenepa/resource-lock +composer remove kenepa/resource-lock +composer require blendbyte/filament-resource-lock +php artisan filament-resource-lock:install ``` -Then run the installation command to publish and run migration(s) +Then update all `use Kenepa\ResourceLock\...` import statements to `use Blendbyte\FilamentResourceLock\...`. + +## Installation ```bash -php artisan resource-lock:install +composer require blendbyte/filament-resource-lock ``` -Register plugin with a panel +Then run the installation command to publish and run the migration: + +```bash +php artisan filament-resource-lock:install +``` + +Register the plugin with a panel: + ```php -use Kenepa\ResourceLock\ResourceLockPlugin; +use Blendbyte\FilamentResourceLock\ResourceLockPlugin; use Filament\Panel; - + public function panel(Panel $panel): Panel { return $panel @@ -49,63 +62,28 @@ public function panel(Panel $panel): Panel } ``` -# Upgrade guides - -## Upgrade to 2.x - -> Notice - Upgrading to Version 2.1.x : -> In case you have published the config, make sure to update the following in your config: -> ```php -> 'resource' => [ -> 'class' => \Kenepa\ResourceLock\Resources\LockResource::class, -> ], -> ``` - -## Upgrade from 2.x to 3.x - -### Breaking Changes - -- **Plugin-based Customization**: Icons, labels, model classes, and access gates now configured via the plugin. -- **Timeout unit changed**: Now uses seconds instead of minutes - ## Usage -The Filament Resource Lock package enables you to lock a resource and prevent other users from editing it at the same -time. Currently, this package only locks -the [EditRecord](https://filamentphp.com/docs/2.x/admin/resources/editing-records) page and the edit modal when editing -a [simple modal resource.](https://filamentphp.com/docs/2.x/admin/resources/getting-started#simple-modal-resources) -Follow the steps below to add locks to your resources. - ### Add Locks to your model -The first step is to add the HasLocks trait to the model of your resource. The HasLocks trait enables the locking -functionality on your model. +Add the `HasLocks` trait to the model you want to lock: ```php -// Post.php - -use Kenepa\ResourceLock\Models\Concerns\HasLocks; +use Blendbyte\FilamentResourceLock\Models\Concerns\HasLocks; class Post extends Model { use HasFactory; use HasLocks; - - protected $table = 'posts'; - - protected $guarded = []; } ``` -### Add Locks to your EditRecord Page +### Add Locks to your EditRecord page -The second step is to add the UsesResourceLock trait to your EditRecord page. The UsesResourceLock trait enables the -locking function on your edit page. +Add the `UsesResourceLock` trait to your `EditRecord` page: ```php -// EditPost.php - -use Kenepa\ResourceLock\Resources\Pages\Concerns\UsesResourceLock; +use Blendbyte\FilamentResourceLock\Resources\Pages\Concerns\UsesResourceLock; class EditPost extends EditRecord { @@ -115,145 +93,90 @@ class EditPost extends EditRecord } ``` -#### Simple modal Resource +### Simple modal resource -If your resource is -a [simple modal](https://filamentphp.com/docs/2.x/admin/resources/getting-started#simple-modal-resources) resource, -you'll need to use the UsesSimpleResourceLock trait instead. +For simple modal resources, use `UsesSimpleResourceLock` instead: ```php -// ManagePosts.php - -use Kenepa\ResourceLock\Resources\Pages\Concerns\UsesSimpleResourceLock; +use Blendbyte\FilamentResourceLock\Resources\Pages\Concerns\UsesSimpleResourceLock; class ManagePosts extends ManageRecords { use UsesSimpleResourceLock; protected static string $resource = PostResource::class; - } ``` -And that's it! Your resource is now able to be locked. Refer to the documentation below for more information on how to -configure the locking functionality. - -## Use polling to detect presence (SPA mode) +### Relation manager locking -To make the resource locking feature possible for SPA we polling based detection to check if a user is still editing the resource. -This is disabled by default but you can enable it in the config: +To lock related records when editing them via a relation manager, add `UsesRelationManagerResourceLock` to your relation manager class. The related model also needs the `HasLocks` trait. ```php -use Kenepa\ResourceLock\ResourceLockPlugin; -use Filament\Panel; - -public function panel(Panel $panel): Panel +use Blendbyte\FilamentResourceLock\Resources\Pages\Concerns\UsesRelationManagerResourceLock; + +class PostCommentsRelationManager extends RelationManager { - return $panel - // ... - ->plugin(ResourceLockPlugin::make() - ->usesPollingToDetectPresence() - ->presencePollingInterval(10) - ->lockTimeout(15) - ); + use UsesRelationManagerResourceLock; + + protected static string $relationship = 'comments'; } ``` -> **Tip:** -> Make sure the lock timeout is **not** set to a value lower than the presence‑polling interval. If it is, the lock may time out before a new heartbeat is sent, allowing another user to acquire the lock while the current user is still editing the page. +When a user opens the edit modal for a related record, it is locked for the duration of the edit session and released when the modal is closed. -### Polling Configuration +## Polling (SPA mode) -When using polling to detect presence, you can configure additional options (by default Livewire will reduce the number of polling requests by 95% until the user revisits the tab. +To support SPA mode, enable polling-based presence detection in the plugin: -): +```php +->plugin(ResourceLockPlugin::make() + ->usesPollingToDetectPresence() + ->presencePollingInterval(10) + ->lockTimeout(15) +) +``` -- **`pollingKeepAlive()`**: Keeps the polling connection alive even when the user has the tab in the background. -- **`pollingVisible()`**: Only polls when the browser tab is visible to the user. This helps reduce server load by pausing polling when users switch to other tabs. +> **Tip:** Make sure the lock timeout is not lower than the polling interval — otherwise the lock may expire before the next heartbeat is sent. -## Resource Lock manager +Additional polling options: +- **`pollingKeepAlive()`**: Keeps polling alive when the tab is in the background. +- **`pollingVisible()`**: Only polls when the browser tab is visible. -filament-resource-lock-art +## Resource Lock Manager -The package also provides a simple way to manage and view all your active and expired locks within your app. And it also -provides a way to quickly unlock all resources or specific locks. +The package includes a UI to view and manage all active and expired locks, and to unlock resources individually or in bulk. ## Configuration -### Access - -filament-resource-lock-art - -You can restrict the access to the **Unlock** button or to the resource manager by adjusting the access variable. -Enabling the "limited" key and -setting it to true allows you to specify either a Laravel Gate class or a permission name from -the [Spatie Permissions package](https://github.com/spatie/laravel-permission). - -```php -use Kenepa\ResourceLock\ResourceLockPlugin; -use Filament\Panel; - -public function panel(Panel $panel): Panel -{ - return $panel - // ... - ->limitedAccessToResourceLockManager() - ->gate('unlock') -} -``` +### Access control -Example +Restrict access to the Unlock button or resource manager using a gate or Spatie permission: ```php - -// Example using gates -// More info about gates: https://laravel.com/docs/authorization#writing-gates -Gate::define('unlock', function (User $user, Post $post) { - return $user->email === 'admin@mail.com'; -}); - -// Example using spatie permission package -Permission::create(['name' => 'unlock']); +->plugin(ResourceLockPlugin::make() + ->limitedAccessToResourceLockManager() + ->gate('unlock') +) ``` -### Using custom models - -Sometimes, you may have a customized implementation for the User model in your application, or you may want to use a -custom class for the ResourceLock functionality. In such cases, you can update the configuration file to specify the new -class you want to use. This will ensure that the ResourceLock functionality works as expected with the new -implementation. +### Custom models ```php -use Kenepa\ResourceLock\ResourceLockPlugin; -use Filament\Panel; - -public function panel(Panel $panel): Panel -{ - return $panel - // ... - ->userModel(\App\Models\CustomUser::class) - ->resourceLockModel(\App\Models\CustomResourceLock::class); -} +->plugin(ResourceLockPlugin::make() + ->userModel(\App\Models\CustomUser::class) + ->resourceLockModel(\App\Models\CustomResourceLock::class) +) ``` -### Displaying the user who has locked the resource - -This package uses actions which allows you to implement your own custom logic. An action class is nothing more than a -simple class with a method that executes some -logic. [Learn more about actions](https://freek.dev/2442-strategies-for-making-laravel-packages-customizable) +### Custom lock owner display -To create a custom action, first create a file within your project and name -it ```CustomGetResourceLockOwnerAction.php```, for -example. In this file, create a new class that extends the ```GetResourceLockOwnerAction``` class and override the -execute -method to return the desired identifier. For example: +Create a custom action class extending `GetResourceLockOwnerAction`: ```php -// CustomGetResourceLockOwnerAction.php - namespace App\Actions; -use Kenepa\ResourceLock\Actions\GetResourceLockOwnerAction; +use Blendbyte\FilamentResourceLock\Actions\GetResourceLockOwnerAction; class CustomResourceLockOwnerAction extends GetResourceLockOwnerAction { @@ -264,80 +187,39 @@ class CustomResourceLockOwnerAction extends GetResourceLockOwnerAction } ``` -Next, register your custom action within the your plugin configuration: +Register it in the plugin: ```php -use Kenepa\ResourceLock\ResourceLockPlugin; -use Filament\Panel; - -public function panel(Panel $panel): Panel -{ - return $panel - // ... - ->plugin( - ResourceLockPlugin::make() - ->resourceLockOwnerAction(\Kenepa\ResourceLock\Actions\CustomGetResourceLockOwnerAction::class) - ); -} +->plugin(ResourceLockPlugin::make() + ->resourceLockOwnerAction(\App\Actions\CustomResourceLockOwnerAction::class) +) ``` ### Overriding default functionality -If you need some custom functionality beyond what the traits provide, you can override the functions that they use. For -example, if you want to change the URL that the "Return" button redirects to, you can override the -resourceLockReturnUrl() function. By default, this button takes you to the index page of the resource, but you can -change it to whatever URL you want by adding your custom implementation in the resourceLockReturnUrl() function. - -For instance, if you want the "Return" button to redirect to https://laracasts.com, you can override the function as -follows: +Override `resourceLockReturnUrl()` to change where the Return button redirects: ```php - public function resourceLockReturnUrl(): string - { - return 'https://laracasts.com'; - } -``` - -Now the return url will redirect to laracasts.com - -This will change the behavior of the "Return" button to redirect to the provided URL. - -## Publishing migrations, configuration and view - -```bash -php artisan vendor:publish --tag="resource-lock-migrations" -php artisan migrate +public function resourceLockReturnUrl(): string +{ + return route('dashboard'); +} ``` -You can publish and run the migrations with: +## Publishing assets ```bash -php artisan vendor:publish --tag="resource-lock-migrations" +# Migrations +php artisan vendor:publish --tag="filament-resource-lock-migrations" php artisan migrate -``` - -Optionally, you can publish the views using - -> Note: Publishing Blade views can introduce breaking changes into your app. If you're interested in how to stay -> safe, [see this article by Dan Harrin](https://filamentphp.com/blog/publishing-views-in-laravel). -```bash -php artisan vendor:publish --tag="resource-lock-views" +# Views +php artisan vendor:publish --tag="filament-resource-lock-views" ``` -## Coming soon - -- Locked status indicator for table rows -- Polling -- Optimistic Locking - -## Changelog - -Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. - ## Contributing -Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. +Please see [GitHub releases](https://github.com/blendbyte/filament-resource-lock/releases) for changelog information. ## License diff --git a/composer.json b/composer.json index 0e6d53a..e139007 100644 --- a/composer.json +++ b/composer.json @@ -1,42 +1,40 @@ { - "name": "kenepa/resource-lock", + "name": "blendbyte/filament-resource-lock", "description": "Filament Resource Lock is a Filament plugin that adds resource locking functionality to your site.", "keywords": [ - "Kenepa", + "Blendbyte", "laravel", - "resource-lock" + "filament-resource-lock" ], - "homepage": "https://github.com/kenepa/resource-lock", + "homepage": "https://github.com/blendbyte/filament-resource-lock", "license": "MIT", "require": { "php": "^8.2", - "filament/filament": "^4.0", - "spatie/laravel-package-tools": "^1.15.0", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0" + "filament/filament": "^5.0", + "spatie/laravel-package-tools": "^1.16.0", + "illuminate/contracts": "^11.28|^12.0|^13.0" }, "require-dev": { "laravel/pint": "^1.0", - "nunomaduro/collision": "^7.0|^8.1", - "nunomaduro/larastan": "^2.0.1", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.7", - "pestphp/pest-plugin-laravel": "^2.0|^3.1", - "pestphp/pest-plugin-livewire": "^2.0|^3.0", + "nunomaduro/collision": "^8.1", + "larastan/larastan": "^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.7", + "pestphp/pest-plugin-laravel": "^3.1", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", - "phpstan/phpstan-phpunit": "^1.0|^2.0", - "spatie/laravel-ray": "^1.26", - "tightenco/duster": "^1.1|^3.1" + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "tightenco/duster": "^3.1" }, "autoload": { "psr-4": { - "Kenepa\\ResourceLock\\": "src", - "Kenepa\\ResourceLock\\Database\\Factories\\": "database/factories" + "Blendbyte\\FilamentResourceLock\\": "src", + "Blendbyte\\FilamentResourceLock\\Database\\Factories\\": "database/factories" } }, "autoload-dev": { "psr-4": { - "Kenepa\\ResourceLock\\Tests\\": "tests" + "Blendbyte\\FilamentResourceLock\\Tests\\": "tests" } }, "scripts": { @@ -51,7 +49,6 @@ "config": { "sort-packages": true, "allow-plugins": { - "composer/package-versions-deprecated": true, "pestphp/pest-plugin": true, "phpstan/extension-installer": true } @@ -59,10 +56,10 @@ "extra": { "laravel": { "providers": [ - "Kenepa\\ResourceLock\\ResourceLockServiceProvider" + "Blendbyte\\FilamentResourceLock\\ResourceLockServiceProvider" ], "aliases": { - "ResourceLock": "Kenepa\\ResourceLock\\Facades\\ResourceLock" + "ResourceLock": "Blendbyte\\FilamentResourceLock\\Facades\\ResourceLock" } } }, diff --git a/config/resource-lock.php b/config/filament-resource-lock.php similarity index 84% rename from config/resource-lock.php rename to config/filament-resource-lock.php index 5687999..8219b90 100644 --- a/config/resource-lock.php +++ b/config/filament-resource-lock.php @@ -1,5 +1,9 @@ [ - 'User' => \App\Models\User::class, + 'User' => User::class, // 'ResourceLock' => null, ], @@ -28,7 +32,7 @@ | */ 'resource' => [ - 'class' => \Kenepa\ResourceLock\Resources\LockResource::class, + 'class' => LockResource::class, ], /* @@ -125,6 +129,22 @@ */ 'actions' => [ - 'get_resource_lock_owner_action' => \Kenepa\ResourceLock\Actions\GetResourceLockOwnerAction::class, + 'get_resource_lock_owner_action' => GetResourceLockOwnerAction::class, + ], + + /* + |-------------------------------------------------------------------------- + | Events + |-------------------------------------------------------------------------- + | + | When enabled, the package dispatches Laravel events for every lock + | lifecycle transition: ResourceLocked, ResourceUnlocked, + | ResourceLockExpired, and ResourceLockForceUnlocked. + | Set to false to disable all event dispatching globally. + | + */ + + 'events' => [ + 'enabled' => true, ], ]; diff --git a/database/factories/ResourceLockFactory.php b/database/factories/ResourceLockFactory.php index 2d4fd2e..f1579ea 100644 --- a/database/factories/ResourceLockFactory.php +++ b/database/factories/ResourceLockFactory.php @@ -1,9 +1,9 @@ - - - - tests - - - - - ./src - - - - - - - - - - - \ No newline at end of file + + + + tests + + + + + + + + + + + + + + + ./src + + + diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index fef1b22..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/resources/dist/.gitkeep b/resources/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/resources/lang/pt_BR/manager.php b/resources/lang/pt_BR/manager.php new file mode 100644 index 0000000..5be2d2e --- /dev/null +++ b/resources/lang/pt_BR/manager.php @@ -0,0 +1,10 @@ + 'Ativo', + 'expired' => 'Expirado', + 'unlock' => 'Desbloquear', + 'unlocked' => 'Recurso desbloqueado', + 'unlocked_selected' => 'Recursos selecionados desbloqueados', + 'unlock_all' => 'Desbloquear todos os recursos', +]; diff --git a/resources/lang/pt_BR/modal.php b/resources/lang/pt_BR/modal.php new file mode 100644 index 0000000..8db5336 --- /dev/null +++ b/resources/lang/pt_BR/modal.php @@ -0,0 +1,8 @@ + 'Esta página foi bloqueada porque outro usuário está editando-a no momento.', + 'locked_notice_user' => 'está editando esta página no momento, portanto ela está bloqueada para edição.', + 'unlock_button' => 'Desbloquear página', + 'return_button' => 'Voltar', +]; diff --git a/resources/views/components/resource-lock-observer.blade.php b/resources/views/components/resource-lock-observer.blade.php index 53a13ff..34b8b94 100644 --- a/resources/views/components/resource-lock-observer.blade.php +++ b/resources/views/components/resource-lock-observer.blade.php @@ -12,7 +12,7 @@ function trackModalContainers() { ['modal-closed'].forEach(eventType => { container.addEventListener(eventType, event => { if (event.detail.id.endsWith('-table-action')) { - Livewire.dispatch('resourceLockObserver::unload') + Livewire.dispatch('resourceLockObserver::unloadSimple') } }); }); @@ -43,7 +43,7 @@ function startObserving() { window.addEventListener('close-modal', event => { if (event.detail.id.endsWith('-table-action')) { - Livewire.dispatch('resourceLockObserver::unload') + Livewire.dispatch('resourceLockObserver::unloadSimple') } }); @@ -72,10 +72,10 @@ function startObserving() {

- {{ __('resource-lock::modal.locked_notice_user') }} + {{ __('filament-resource-lock::modal.locked_notice_user') }}

- {{ __('resource-lock::modal.locked_notice') }} + {{ __('filament-resource-lock::modal.locked_notice') }}

@@ -89,7 +89,7 @@ function startObserving() { @if ($isAllowedToUnlock) @endif @@ -97,7 +97,7 @@ function startObserving() { class="fi-btn fi-size-md" :href="url" tabindex="-1"> - {{ __('resource-lock::modal.return_button') }} + {{ __('filament-resource-lock::modal.return_button') }} diff --git a/src/Actions/GetResourceLockOwnerAction.php b/src/Actions/GetResourceLockOwnerAction.php index 2a5c784..eb84beb 100644 --- a/src/Actions/GetResourceLockOwnerAction.php +++ b/src/Actions/GetResourceLockOwnerAction.php @@ -1,6 +1,6 @@ info('All resource locks successfully removed.'); } catch (Exception $e) { - $this->error('Failed to clear resource locks: ' . $e->getMessage()); + $this->error('Failed to clear resource locks: '.$e->getMessage()); return; } diff --git a/src/Console/Commands/ResourceLockClearExpiredCommand.php b/src/Console/Commands/ResourceLockClearExpiredCommand.php index dbb4460..a2d289f 100644 --- a/src/Console/Commands/ResourceLockClearExpiredCommand.php +++ b/src/Console/Commands/ResourceLockClearExpiredCommand.php @@ -1,14 +1,14 @@ info('All expired resource locks successfully removed.'); } catch (Exception $e) { - $this->error('Failed to clear expired resource locks: ' . $e->getMessage()); + $this->error('Failed to clear expired resource locks: '.$e->getMessage()); return; } diff --git a/src/Events/ResourceLockExpired.php b/src/Events/ResourceLockExpired.php new file mode 100644 index 0000000..ec283c5 --- /dev/null +++ b/src/Events/ResourceLockExpired.php @@ -0,0 +1,17 @@ +shouldLimitUnlockerAccess()) { $this->isAllowedToUnlock = true; - } elseif (ResourceLockPlugin::get()->shouldLimitUnlockerAccess() && Gate::allows(ResourceLockPlugin::get()->getUnlockerGate())) { - $this->isAllowedToUnlock = true; + } else { + $gate = ResourceLockPlugin::get()->getUnlockerGate(); + if ($gate !== null && Gate::allows($gate)) { + $this->isAllowedToUnlock = true; + } } } diff --git a/src/Models/Concerns/HasLocks.php b/src/Models/Concerns/HasLocks.php index 6f8308d..ce851e4 100644 --- a/src/Models/Concerns/HasLocks.php +++ b/src/Models/Concerns/HasLocks.php @@ -1,11 +1,15 @@ isUnlocked()) { + // An expired record exists — dispatch expiry event, clean it up + if ($this->resourceLock !== null && $this->resourceLock->isExpired()) { + $expiredUserId = $this->resourceLock->user_id; + $this->resourceLock()->delete(); + $this->unsetRelation('resourceLock'); + + if (config('filament-resource-lock.events.enabled', true)) { + ResourceLockExpired::dispatch($this, $expiredUserId); + } + } + $resourceLockModel = ResourceLockPlugin::get()->getResourceLockModel(); $guard = $this->getCurrentAuthGuardName(); $resourceLock = new $resourceLockModel; $resourceLock->user_id = auth()->guard($guard)->user()->id; $this->resourceLock()->save($resourceLock); + if (config('filament-resource-lock.events.enabled', true)) { + ResourceLocked::dispatch($this, $resourceLock->user_id); + } + return true; } @@ -112,8 +131,17 @@ public function unlock(bool $force = false): bool { if ($this->isLocked()) { if ($force || $this->lockCreatedByCurrentUser() || $this->hasExpiredLock()) { + $originalUserId = $this->resourceLock->user_id; $this->resourceLock()->delete(); + if (config('filament-resource-lock.events.enabled', true)) { + if ($force) { + ResourceLockForceUnlocked::dispatch($this, $originalUserId, auth()->id()); + } else { + ResourceUnlocked::dispatch($this, $originalUserId); + } + } + return true; } } diff --git a/src/Models/ResourceLock.php b/src/Models/ResourceLock.php index 2c43196..64b7584 100644 --- a/src/Models/ResourceLock.php +++ b/src/Models/ResourceLock.php @@ -1,14 +1,18 @@ Blade::render('@livewire(\'resource-lock-observer\')'), + fn (): string => Blade::render('@livewire(\'filament-resource-lock-observer\')'), ); } @@ -102,7 +102,7 @@ public function displayResourceLockOwner(bool $display = true): static public function shouldDisplayResourceLockOwner(): bool { - return $this->displayResourceLockOwner ?? config('resource-lock.lock_notice.display_resource_lock_owner', true); + return $this->displayResourceLockOwner ?? config('filament-resource-lock.lock_notice.display_resource_lock_owner', true); } public function navigationBadge(bool $show = true): static @@ -114,7 +114,7 @@ public function navigationBadge(bool $show = true): static public function shouldShowNavigationBadge(): bool { - return $this->navigationBadge ?? config('resource-lock.manager.navigation_badge', false); + return $this->navigationBadge ?? config('filament-resource-lock.manager.navigation_badge', false); } public function navigationIcon(?string $icon): static @@ -126,7 +126,7 @@ public function navigationIcon(?string $icon): static public function getNavigationIcon(): ?string { - return $this->navigationIcon ?? config('resource-lock.manager.navigation_icon', 'heroicon-o-lock-closed'); + return $this->navigationIcon ?? config('filament-resource-lock.manager.navigation_icon', 'heroicon-o-lock-closed'); } public function navigationLabel(?string $label): static @@ -138,7 +138,7 @@ public function navigationLabel(?string $label): static public function getNavigationLabel(): string { - return __($this->navigationLabel ?? config('resource-lock.manager.navigation_label', 'Resource Lock Manager')); + return __($this->navigationLabel ?? config('filament-resource-lock.manager.navigation_label', 'Resource Lock Manager')); } public function pluralLabel(?string $label): static @@ -150,7 +150,7 @@ public function pluralLabel(?string $label): static public function getPluralLabel(): string { - return __($this->pluralLabel ?? config('resource-lock.manager.plural_label', 'Resource Locks')); + return __($this->pluralLabel ?? config('filament-resource-lock.manager.plural_label', 'Resource Locks')); } public function navigationGroup(?string $group): static @@ -162,7 +162,7 @@ public function navigationGroup(?string $group): static public function getNavigationGroup(): ?string { - return $this->navigationGroup ?? config('resource-lock.manager.navigation_group'); + return $this->navigationGroup ?? config('filament-resource-lock.manager.navigation_group'); } public function navigationSort(?int $sort): static @@ -174,7 +174,7 @@ public function navigationSort(?int $sort): static public function getNavigationSort(): ?int { - return $this->navigationSort ?? config('resource-lock.manager.navigation_sort'); + return $this->navigationSort ?? config('filament-resource-lock.manager.navigation_sort'); } public function limitedAccessToResourceLockManager(bool $limited = true): static @@ -186,7 +186,7 @@ public function limitedAccessToResourceLockManager(bool $limited = true): static public function shouldLimitAccessToResourceLockManager(): bool { - return $this->limitedAccessToResourceLockManager ?? config('resource-lock.manager.limited_access', false); + return $this->limitedAccessToResourceLockManager ?? config('filament-resource-lock.manager.limited_access', false); } public function gate(?string $gate): static @@ -198,7 +198,7 @@ public function gate(?string $gate): static public function getGate(): ?string { - return $this->gate ?? config('resource-lock.manager.gate', null); + return $this->gate ?? config('filament-resource-lock.manager.gate', null); } public function registerNavigation(bool $register = true): static @@ -210,7 +210,7 @@ public function registerNavigation(bool $register = true): static public function shouldRegisterNavigation(): bool { - return $this->shouldRegisterNavigation ?? config('resource-lock.manager.should_register_navigation', true); + return $this->shouldRegisterNavigation ?? config('filament-resource-lock.manager.should_register_navigation', true); } public function unlockerLimitedAccess(bool $limited = true): static @@ -222,7 +222,7 @@ public function unlockerLimitedAccess(bool $limited = true): static public function shouldLimitUnlockerAccess(): bool { - return $this->unlockerLimitedAccess ?? config('resource-lock.unlocker.limited_access', false); + return $this->unlockerLimitedAccess ?? config('filament-resource-lock.unlocker.limited_access', false); } public function unlockerGate(?string $gate): static @@ -234,7 +234,7 @@ public function unlockerGate(?string $gate): static public function getUnlockerGate(): ?string { - return $this->unlockerGate ?? config('resource-lock.unlocker.gate', null); + return $this->unlockerGate ?? config('filament-resource-lock.unlocker.gate', null); } public function resourceClass(?string $class): static @@ -246,7 +246,7 @@ public function resourceClass(?string $class): static public function getResourceClass(): string { - return $this->resourceClass ?? config('resource-lock.resource.class', LockResource::class); + return $this->resourceClass ?? config('filament-resource-lock.resource.class', LockResource::class); } public function userModel(?string $model): static @@ -258,7 +258,7 @@ public function userModel(?string $model): static public function getUserModel(): string { - return $this->userModel ?? config('resource-lock.models.User', 'App\\Models\\User'); + return $this->userModel ?? config('filament-resource-lock.models.User', 'App\\Models\\User'); } public function resourceLockModel(?string $model): static @@ -270,7 +270,7 @@ public function resourceLockModel(?string $model): static public function getResourceLockModel(): string { - return $this->resourceLockModel ?? config('resource-lock.models.ResourceLock', ResourceLock::class); + return $this->resourceLockModel ?? config('filament-resource-lock.models.ResourceLock', ResourceLock::class); } public function lockTimeout(?int $seconds): static @@ -282,7 +282,7 @@ public function lockTimeout(?int $seconds): static public function getLockTimeout(): int { - return $this->lockTimeout ?? config('resource-lock.lock_timeout', 600); + return $this->lockTimeout ?? config('filament-resource-lock.lock_timeout', 600); } public function checkLocksBeforeSaving(bool $check = true): static @@ -294,7 +294,7 @@ public function checkLocksBeforeSaving(bool $check = true): static public function shouldCheckLocksBeforeSaving(): bool { - return $this->checkLocksBeforeSaving ?? config('resource-lock.check_locks_before_saving', true); + return $this->checkLocksBeforeSaving ?? config('filament-resource-lock.check_locks_before_saving', true); } public function resourceLockOwnerAction(?string $action): static @@ -306,7 +306,7 @@ public function resourceLockOwnerAction(?string $action): static public function getResourceLockOwnerAction(): string { - return $this->resourceLockOwnerAction ?? config('resource-lock.actions.get_resource_lock_owner_action', \Kenepa\ResourceLock\Actions\GetResourceLockOwnerAction::class); + return $this->resourceLockOwnerAction ?? config('filament-resource-lock.actions.get_resource_lock_owner_action', GetResourceLockOwnerAction::class); } public function usesPollingToDetectPresence(bool $enable = true): static @@ -356,4 +356,16 @@ public function shouldUsePollingVisible(): bool { return $this->pollingVisible ?? false; } + + public function enableEvents(bool $enable = true): static + { + $this->eventsEnabled = $enable; + + return $this; + } + + public function shouldDispatchEvents(): bool + { + return $this->eventsEnabled ?? config('filament-resource-lock.events.enabled', true); + } } diff --git a/src/ResourceLockServiceProvider.php b/src/ResourceLockServiceProvider.php index ae08e32..a8cc255 100644 --- a/src/ResourceLockServiceProvider.php +++ b/src/ResourceLockServiceProvider.php @@ -1,16 +1,31 @@ callAfterResolving('livewire.finder', function ($finder) { + $finder->addComponent('filament-resource-lock-observer', class: Http\Livewire\ResourceLockObserver::class); + }); + } + + public function packageBooted(): void + { + parent::packageBooted(); + + Livewire::component('filament-resource-lock-observer', Http\Livewire\ResourceLockObserver::class); + } public function configurePackage(Package $package): void { @@ -23,7 +38,7 @@ public function configurePackage(Package $package): void $command ->publishMigrations() ->askToRunMigrations() - ->askToStarRepoOnGitHub('kenepa/resource-lock'); + ->askToStarRepoOnGitHub('blendbyte/filament-resource-lock'); }) ->hasCommand(ResourceLockClearCommand::class) ->hasCommand(ResourceLockClearExpiredCommand::class); diff --git a/src/Resources/LockResource.php b/src/Resources/LockResource.php index e1a1b7c..378e5e6 100644 --- a/src/Resources/LockResource.php +++ b/src/Resources/LockResource.php @@ -1,17 +1,19 @@ columns([ @@ -48,7 +50,8 @@ public static function table(Table $table): Tables\Table TextColumn::make('lockable_type')->label(__('Lockable type')), TextColumn::make('created_at')->label(__('Created at')), TextColumn::make('updated_at')->label(__('Updated at')), - TextColumn::make('updated_at')->label(__('Expired')) + TextColumn::make('lock_status')->label(__('Expired')) + ->state(fn ($record) => $record->isExpired()) ->badge() ->color(static function ($record): string { if ($record->isExpired()) { @@ -65,28 +68,48 @@ public static function table(Table $table): Tables\Table return 'heroicon-o-lock-closed'; })->formatStateUsing(static function ($record) { if ($record->isExpired()) { - return __('resource-lock::manager.expired'); + return __('filament-resource-lock::manager.expired'); } - return __('resource-lock::manager.active'); + return __('filament-resource-lock::manager.active'); }), ]) ->filters([ // ]) - ->actions([ + ->recordActions([ DeleteAction::make() + ->before(function (ResourceLock $record) { + if (config('filament-resource-lock.events.enabled', true)) { + ResourceLockForceUnlocked::dispatch( + $record->lockable, + $record->user_id, + auth()->id() + ); + } + }) ->icon('heroicon-o-lock-open') - ->successNotificationTitle(__('resource-lock::manager.unlocked')) - ->label(__('resource-lock::manager.unlock')), + ->successNotificationTitle(__('filament-resource-lock::manager.unlocked')) + ->label(__('filament-resource-lock::manager.unlock')), ]) - ->bulkActions([ + ->toolbarActions([ DeleteBulkAction::make() + ->before(function (Collection $records) { + if (config('filament-resource-lock.events.enabled', true)) { + $records->each(function (ResourceLock $record) { + ResourceLockForceUnlocked::dispatch( + $record->lockable, + $record->user_id, + auth()->id() + ); + }); + } + }) ->deselectRecordsAfterCompletion() ->requiresConfirmation() ->icon('heroicon-o-lock-open') - ->successNotificationTitle(__('resource-lock::manager.unlocked_selected')) - ->label(__('resource-lock::manager.unlock')), + ->successNotificationTitle(__('filament-resource-lock::manager.unlocked_selected')) + ->label(__('filament-resource-lock::manager.unlock')), ]); } @@ -100,7 +123,9 @@ public static function getPages(): array public static function canViewAny(): bool { if (ResourceLockPlugin::get()->shouldLimitAccessToResourceLockManager()) { - return Gate::allows(ResourceLockPlugin::get()->getGate()); + $gate = ResourceLockPlugin::get()->getGate(); + + return $gate !== null && Gate::allows($gate); } return true; @@ -109,7 +134,9 @@ public static function canViewAny(): bool public static function canDeleteAny(): bool { if (ResourceLockPlugin::get()->shouldLimitAccessToResourceLockManager()) { - return Gate::allows(ResourceLockPlugin::get()->getGate()); + $gate = ResourceLockPlugin::get()->getGate(); + + return $gate !== null && Gate::allows($gate); } return true; @@ -121,7 +148,7 @@ public static function getNavigationBadge(): ?string return null; } - return static::getModel()::count(); + return (string) static::getModel()::count(); } public static function getNavigationLabel(): string diff --git a/src/Resources/LockResource/ManageResourceLocks.php b/src/Resources/LockResource/ManageResourceLocks.php index e51015e..f8941d1 100644 --- a/src/Resources/LockResource/ManageResourceLocks.php +++ b/src/Resources/LockResource/ManageResourceLocks.php @@ -1,11 +1,12 @@ label(__('resource-lock::manager.unlock_all')) + Action::make(__('filament-resource-lock::manager.unlock_all')) + ->label(__('filament-resource-lock::manager.unlock_all')) ->icon('heroicon-o-lock-open') - ->action(fn () => ResourceLockPlugin::get()->getResourceLockModel()::truncate()) + ->action(function () { + $lockModel = ResourceLockPlugin::get()->getResourceLockModel(); + + if (config('filament-resource-lock.events.enabled', true)) { + $lockModel::with('lockable')->get()->each(function ($lock) { + ResourceLockForceUnlocked::dispatch( + $lock->lockable, + $lock->user_id, + auth()->id() + ); + }); + } + + $lockModel::truncate(); + }) ->requiresConfirmation(), ]; } diff --git a/src/Resources/Pages/Concerns/UsesLocks.php b/src/Resources/Pages/Concerns/UsesLocks.php index 84f2cee..df678a8 100644 --- a/src/Resources/Pages/Concerns/UsesLocks.php +++ b/src/Resources/Pages/Concerns/UsesLocks.php @@ -1,6 +1,8 @@ dispatch('disablePollingInResourceLockObserver'); } + #[On('resourceLockObserver::renewLock')] public function renewLock() { $record = $this->record ?? $this->resourceRecord; diff --git a/src/Resources/Pages/Concerns/UsesRelationManagerResourceLock.php b/src/Resources/Pages/Concerns/UsesRelationManagerResourceLock.php new file mode 100644 index 0000000..cc972c3 --- /dev/null +++ b/src/Resources/Pages/Concerns/UsesRelationManagerResourceLock.php @@ -0,0 +1,84 @@ +parentClass = get_class($this->getRelationship()->getParent()); + $this->relatedClass = get_class($this->getRelationship()->getRelated()); + } + + #[On('resourceLockObserver::unlock')] + public function resourceLockObserverUnlock() + { + if ($this->relatedRecord) { + if ($this->relatedRecord->unlock(force: true)) { + $this->closeLockedResourceModal(); + $this->relatedRecord->refresh(); + $this->relatedRecord->lock(); + } + } else { + $ownerRecord = $this->getOwnerRecord(); + if ($ownerRecord->unlock(force: true)) { + $this->closeLockedResourceModal(); + $ownerRecord->refresh(); + $ownerRecord->lock(); + } + } + } + + public function mountTableAction(string $name, ?string $record = null, array $arguments = []): mixed + { + parent::mountTableAction($name, $record); + + if ($name === 'edit') { + $this->relatedRecord = $this->relatedClass::find($record); + $this->checkIfResourceLockHasExpired($this->relatedRecord); + $this->lockResource($this->relatedRecord); + } + + return null; + } + + public function unmountTableAction(bool $shouldCancelParentActions = true): void + { + if ($this->mountedTableActionRecord) { + $this->relatedRecord = $this->relatedClass::find($this->mountedTableActionRecord); + $this->relatedRecord->unlock(); + } + + parent::unmountTableAction($shouldCancelParentActions); + } + + public function resourceLockReturnUrl() + { + $parentResource = filament()->getCurrentPanel()->getModelResource( + $this->getRelationship()->getParent() + ); + + return $parentResource::getUrl('edit', ['record' => $this->getOwnerRecord()->id]); + } + + public function getResourceLockOwner(): void + { + if ($this->relatedRecord?->resourceLock && ResourceLockPlugin::get()->shouldDisplayResourceLockOwner()) { + $getResourceLockOwnerActionClass = ResourceLockPlugin::get()->getResourceLockOwnerAction(); + $getResourceLockOwnerAction = app($getResourceLockOwnerActionClass); + + $this->resourceLockOwner = $getResourceLockOwnerAction->execute($this->relatedRecord->resourceLock->user); + } + } +} diff --git a/src/Resources/Pages/Concerns/UsesResourceLock.php b/src/Resources/Pages/Concerns/UsesResourceLock.php index 8dcf385..a8e853e 100644 --- a/src/Resources/Pages/Concerns/UsesResourceLock.php +++ b/src/Resources/Pages/Concerns/UsesResourceLock.php @@ -1,8 +1,9 @@ listeners = array_merge($this->listeners, [ - 'resourceLockObserver::init' => 'resourceLockObserverInit', - 'resourceLockObserver::unload' => 'resourceLockObserverUnload', - 'resourceLockObserver::unlock' => 'resourceLockObserverUnlock', - 'resourceLockObserver::renewLock' => 'renewLock', - ]); - } - - /* - * This function is triggered when the resource lock observer component has been loaded. - * The resource lock observer is a livewire component that triggers function based - * on certain states and events that are happening on the page. - */ + #[On('resourceLockObserver::init')] public function resourceLockObserverInit() { $this->returnUrl = $this->getResource()::getUrl('index'); @@ -46,6 +28,7 @@ public function resourceLockObserverInit() $this->setupPolling(); } + #[On('resourceLockObserver::unload')] public function resourceLockObserverUnload() { $this->record->unlock(); @@ -56,11 +39,15 @@ public function resourceLockObserverUnload() * presented to the users. This action is seen as forced unlock and will replace any lock * That is currently in place for that specific resource. Hone this power with care. */ + #[On('resourceLockObserver::unlock')] public function resourceLockObserverUnlock() { - if ($this->record->unlock(force: true)) { - $this->closeLockedResourceModal(); - $this->record->lock(); + if (is_null($this->activeRelationManager ?? null)) { + if ($this->record->unlock(force: true)) { + $this->closeLockedResourceModal(); + $this->record->refresh(); + $this->record->lock(); + } } } diff --git a/src/Resources/Pages/Concerns/UsesSimpleResourceLock.php b/src/Resources/Pages/Concerns/UsesSimpleResourceLock.php index 9c91cac..b9ab2e3 100644 --- a/src/Resources/Pages/Concerns/UsesSimpleResourceLock.php +++ b/src/Resources/Pages/Concerns/UsesSimpleResourceLock.php @@ -1,8 +1,9 @@ listeners = array_merge($this->listeners, [ - 'resourceLockObserver::init' => 'resourceLockObserverInit', - 'resourceLockObserver::unload' => 'resourceLockObserverUnload', - 'resourceLockObserver::unlock' => 'resourceLockObserverUnlock', - 'resourceLockObserver::renewLock' => 'renewLock', - ]); - } - public function mountTableAction(string $name, ?string $record = null, array $arguments = []): mixed { parent::mountTableAction($name, $record); @@ -54,12 +45,14 @@ public function callMountedTableAction(array $arguments = []): mixed return null; } + #[On('resourceLockObserver::unloadSimple')] public function resourceLockObserverUnload() { $this->resourceRecord->unlock(); $this->disablePolling(); } + #[On('resourceLockObserver::unlock')] public function resourceLockObserverUnlock() { if ($this->resourceRecord->unlock(force: true)) { diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 86a17ee..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,21 +0,0 @@ -const colors = require('tailwindcss/colors') - -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./resources/views/**/*.blade.php', './src/**/*.php'], - darkMode: 'class', - theme: { - extend: { - colors: { - danger: colors.rose, - primary: colors.amber, - success: colors.green, - warning: colors.amber, - }, - }, - }, - corePlugins: { - preflight: false, - }, - plugins: [], -} diff --git a/tests/Feature/ConfigureResourceLockPluginTest.php b/tests/Feature/ConfigureResourceLockPluginTest.php index 0bb2d3e..e1eba4d 100644 --- a/tests/Feature/ConfigureResourceLockPluginTest.php +++ b/tests/Feature/ConfigureResourceLockPluginTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -use Kenepa\ResourceLock\ResourceLockPlugin; +use Blendbyte\FilamentResourceLock\ResourceLockPlugin; function getResourceLockNavigationItem($panel, string $group = 'Settings', string $label = 'Resource Lock Manager') { $navigationItems = $panel->getNavigation(); $navigationItem = array_find($navigationItems, function ($value, $key) use ($group) { - return str_contains($key, $group); + return str_contains($key, $group); }); return $navigationItem->getItems()[$label]; @@ -97,7 +97,6 @@ function getResourceLockNavigationItem($panel, string $group = 'Settings', strin expect($plugin->shouldUsePollingKeepAlive())->toBeTrue(); }); - it('uses default visible value when not configured', function () { $plugin = ResourceLockPlugin::make(); diff --git a/tests/Feature/ResourceLockCommandTest.php b/tests/Feature/ResourceLockCommandTest.php index fdaa76c..ec463ea 100644 --- a/tests/Feature/ResourceLockCommandTest.php +++ b/tests/Feature/ResourceLockCommandTest.php @@ -1,6 +1,6 @@ expectsOutput('Removing 2 resource lock(s)...') ->expectsOutput('All resource locks successfully removed.') ->assertExitCode(0); @@ -37,7 +37,7 @@ assertDatabaseCount(ResourceLock::class, 1); // Act - user confirms - artisan('resource-lock:clear') + artisan('filament-resource-lock:clear') ->expectsConfirmation('Are you sure you want to clear all resource locks? This action cannot be undone.', 'yes') ->expectsOutput('Removing 1 resource lock(s)...') ->expectsOutput('All resource locks successfully removed.') @@ -56,7 +56,7 @@ assertDatabaseCount(ResourceLock::class, 1); // Act - user declines - artisan('resource-lock:clear') + artisan('filament-resource-lock:clear') ->expectsConfirmation('Are you sure you want to clear all resource locks? This action cannot be undone.', 'no') ->expectsOutput('Operation cancelled.') ->assertExitCode(0); @@ -70,7 +70,7 @@ assertDatabaseCount(ResourceLock::class, 0); // Act - artisan('resource-lock:clear --force') + artisan('filament-resource-lock:clear --force') ->expectsOutput('No resource locks found to clear.') ->assertExitCode(0); @@ -97,7 +97,7 @@ assertDatabaseCount(ResourceLock::class, 3); // Act - artisan('resource-lock:clear-expired --force') + artisan('filament-resource-lock:clear-expired --force') ->expectsOutput('Removing 2 expired resource lock(s)...') ->expectsOutput('All expired resource locks successfully removed.') ->assertExitCode(0); @@ -120,7 +120,7 @@ assertDatabaseCount(ResourceLock::class, 1); // Act - user confirms - artisan('resource-lock:clear-expired') + artisan('filament-resource-lock:clear-expired') ->expectsConfirmation('Are you sure you want to clear all expired resource locks? This action cannot be undone.', 'yes') ->expectsOutput('Removing 1 expired resource lock(s)...') ->expectsOutput('All expired resource locks successfully removed.') @@ -139,7 +139,7 @@ assertDatabaseCount(ResourceLock::class, 1); // Act - user declines - artisan('resource-lock:clear-expired') + artisan('filament-resource-lock:clear-expired') ->expectsConfirmation('Are you sure you want to clear all expired resource locks? This action cannot be undone.', 'no') ->expectsOutput('Operation cancelled.') ->assertExitCode(0); @@ -157,7 +157,7 @@ assertDatabaseCount(ResourceLock::class, 1); // Act - artisan('resource-lock:clear-expired --force') + artisan('filament-resource-lock:clear-expired --force') ->expectsOutput('No expired resource locks found to clear.') ->assertExitCode(0); @@ -183,7 +183,7 @@ assertDatabaseCount(ResourceLock::class, 4); // Act - artisan('resource-lock:clear-expired --force') + artisan('filament-resource-lock:clear-expired --force') ->expectsOutput('Removing 2 expired resource lock(s)...') ->expectsOutput('All expired resource locks successfully removed.') ->assertExitCode(0); @@ -204,7 +204,7 @@ assertDatabaseCount(ResourceLock::class, 0); // Act - artisan('resource-lock:clear-expired --force') + artisan('filament-resource-lock:clear-expired --force') ->expectsOutput('No expired resource locks found to clear.') ->assertExitCode(0); diff --git a/tests/Feature/ResourceLockEventsTest.php b/tests/Feature/ResourceLockEventsTest.php new file mode 100644 index 0000000..7295ea4 --- /dev/null +++ b/tests/Feature/ResourceLockEventsTest.php @@ -0,0 +1,103 @@ +callAction('Unlock all resources'); + + Event::assertDispatchedTimes(ResourceLockForceUnlocked::class, 2); +}); + +it('dispatches no events on Unlock All when events are disabled', function () { + Event::fake([ResourceLockForceUnlocked::class]); + config(['filament-resource-lock.events.enabled' => false]); + + $user = createUser(); + actingAs($user); + + $post = createPost(); + createActiveResourceLock($user, $post); + + Livewire::test(ManageResourceLocks::class) + ->callAction('Unlock all resources'); + + Event::assertNothingDispatched(); +}); + +it('dispatches ResourceLockForceUnlocked with correct lock owner on Unlock All', function () { + Event::fake([ResourceLockForceUnlocked::class]); + + $lockOwner = createUser(); + $actor = createUser(); + actingAs($actor); + + $post = createPost(); + createActiveResourceLock($lockOwner, $post); + + Livewire::test(ManageResourceLocks::class) + ->callAction('Unlock all resources'); + + Event::assertDispatched(ResourceLockForceUnlocked::class, function ($event) use ($post, $lockOwner, $actor) { + return $event->lockable->id === $post->id + && $event->originalUserId === $lockOwner->id + && $event->actorUserId === $actor->id; + }); +}); + +it('dispatches ResourceLockForceUnlocked when a row is deleted in the Lock Manager', function () { + Event::fake([ResourceLockForceUnlocked::class]); + + $lockOwner = createUser(); + $actor = createUser(); + actingAs($actor); + + $post = createPost(); + $lock = createActiveResourceLock($lockOwner, $post); + + Livewire::test(ManageResourceLocks::class) + ->callTableAction('delete', $lock); + + Event::assertDispatched(ResourceLockForceUnlocked::class, function ($event) use ($post, $lockOwner, $actor) { + return $event->lockable->id === $post->id + && $event->originalUserId === $lockOwner->id + && $event->actorUserId === $actor->id; + }); +}); + +it('dispatches ResourceLockForceUnlocked for each row on bulk delete in the Lock Manager', function () { + Event::fake([ResourceLockForceUnlocked::class]); + + $lockOwner = createUser(); + $actor = createUser(); + actingAs($actor); + + $post1 = createPost(); + $post2 = createPost(); + $lock1 = createActiveResourceLock($lockOwner, $post1); + $lock2 = createActiveResourceLock($lockOwner, $post2); + + Livewire::test(ManageResourceLocks::class) + ->callTableBulkAction('delete', [$lock1, $lock2]); + + Event::assertDispatchedTimes(ResourceLockForceUnlocked::class, 2); +}); diff --git a/tests/Feature/ResourceLockObserverTest.php b/tests/Feature/ResourceLockObserverTest.php index 3fc68dc..e495e57 100644 --- a/tests/Feature/ResourceLockObserverTest.php +++ b/tests/Feature/ResourceLockObserverTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Kenepa\ResourceLock\Http\Livewire\ResourceLockObserver; -use Kenepa\ResourceLock\ResourceLockPlugin; +use Blendbyte\FilamentResourceLock\Http\Livewire\ResourceLockObserver; +use Blendbyte\FilamentResourceLock\ResourceLockPlugin; use Livewire\Livewire; describe('ResourceLockObserver Keep-Alive Configuration', function () { @@ -63,8 +63,8 @@ $html = $component->viewData('usesPollingToDetectPresence') ? $component->viewData('pollingKeepAlive') - ? 'wire:poll.keep-alive.' . $component->viewData('presencePollingInterval') . 's' - : 'wire:poll.' . $component->viewData('presencePollingInterval') . 's' + ? 'wire:poll.keep-alive.'.$component->viewData('presencePollingInterval').'s' + : 'wire:poll.'.$component->viewData('presencePollingInterval').'s' : null; expect($html)->toBe('wire:poll.keep-alive.10s'); @@ -82,8 +82,8 @@ $html = $component->viewData('usesPollingToDetectPresence') ? $component->viewData('pollingKeepAlive') - ? 'wire:poll.keep-alive.' . $component->viewData('presencePollingInterval') . 's' - : 'wire:poll.' . $component->viewData('presencePollingInterval') . 's' + ? 'wire:poll.keep-alive.'.$component->viewData('presencePollingInterval').'s' + : 'wire:poll.'.$component->viewData('presencePollingInterval').'s' : null; expect($html)->toBe('wire:poll.15s'); @@ -117,7 +117,6 @@ expect($component->get('usesPollingToDetectPresence'))->toBeTrue(); }); - it('resets visible when disabling polling', function () { $panel = filament()->getDefaultPanel(); $panel->plugin(ResourceLockPlugin::make() @@ -144,11 +143,11 @@ $component = Livewire::test(ResourceLockObserver::class); $component->call('enablePolling'); - $html = $component->viewData('usesPollingToDetectPresence') - ? 'wire:poll' . - ($component->viewData('pollingKeepAlive') ? '.keep-alive' : '') . - ($component->viewData('pollingVisible') ? '.visible' : '') . - '.' . $component->viewData('presencePollingInterval') . 's' + $html = $component->viewData('usesPollingToDetectPresence') + ? 'wire:poll'. + ($component->viewData('pollingKeepAlive') ? '.keep-alive' : ''). + ($component->viewData('pollingVisible') ? '.visible' : ''). + '.'.$component->viewData('presencePollingInterval').'s' : null; expect($html)->toBe('wire:poll.visible.20s'); @@ -165,11 +164,11 @@ $component = Livewire::test(ResourceLockObserver::class); $component->call('enablePolling'); - $html = $component->viewData('usesPollingToDetectPresence') - ? 'wire:poll' . - ($component->viewData('pollingKeepAlive') ? '.keep-alive' : '') . - ($component->viewData('pollingVisible') ? '.visible' : '') . - '.' . $component->viewData('presencePollingInterval') . 's' + $html = $component->viewData('usesPollingToDetectPresence') + ? 'wire:poll'. + ($component->viewData('pollingKeepAlive') ? '.keep-alive' : ''). + ($component->viewData('pollingVisible') ? '.visible' : ''). + '.'.$component->viewData('presencePollingInterval').'s' : null; expect($html)->toBe('wire:poll.keep-alive.visible.30s'); diff --git a/tests/Filament/Resources/LockResource/LockResourceTest.php b/tests/Filament/Resources/LockResource/LockResourceTest.php index cb9a720..9122217 100644 --- a/tests/Filament/Resources/LockResource/LockResourceTest.php +++ b/tests/Filament/Resources/LockResource/LockResourceTest.php @@ -2,16 +2,15 @@ declare(strict_types=1); -use Kenepa\ResourceLock\Resources\LockResource\ManageResourceLocks; - -use function Pest\Livewire\livewire; +use Blendbyte\FilamentResourceLock\Resources\LockResource\ManageResourceLocks; +use Livewire\Livewire; it('can render lock resource index page', function () { - livewire(ManageResourceLocks::class) + Livewire::test(ManageResourceLocks::class) ->assertSuccessful(); }); it('can render the unlock all resources button', function () { - livewire(ManageResourceLocks::class) + Livewire::test(ManageResourceLocks::class) ->assertSee('Unlock all resources'); }); diff --git a/tests/Fixtures/AdminPanelProvider.php b/tests/Fixtures/AdminPanelProvider.php index aa86170..cc1f59b 100644 --- a/tests/Fixtures/AdminPanelProvider.php +++ b/tests/Fixtures/AdminPanelProvider.php @@ -1,7 +1,8 @@ in(__DIR__); @@ -37,7 +37,7 @@ function createExpiredResourceLock(User $user, Post $post): ResourceLock $resourceLock = (new ResourceLock)->forceFill([ 'updated_at' => Carbon::now()->subMinutes(30), 'user_id' => $user->id, - 'lockable_type' => 'Kenepa\ResourceLock\Tests\Resources\Models\Post', + 'lockable_type' => 'Blendbyte\FilamentResourceLock\Tests\Resources\Models\Post', 'lockable_id' => $post->id, ]); @@ -51,7 +51,7 @@ function createActiveResourceLock($user, $post): ResourceLock $resourceLock = (new ResourceLock)->forceFill([ 'updated_at' => Carbon::now(), 'user_id' => $user->id, - 'lockable_type' => 'Kenepa\ResourceLock\Tests\Resources\Models\Post', + 'lockable_type' => 'Blendbyte\FilamentResourceLock\Tests\Resources\Models\Post', 'lockable_id' => $post->id, ]); diff --git a/tests/Resources/Models/Post.php b/tests/Resources/Models/Post.php index 2292173..2ac652c 100644 --- a/tests/Resources/Models/Post.php +++ b/tests/Resources/Models/Post.php @@ -1,10 +1,10 @@ 'Kenepa\\ResourceLock\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Blendbyte\\FilamentResourceLock\\Database\\Factories\\'.class_basename($modelName).'Factory' ); } @@ -39,26 +38,25 @@ public function getEnvironmentSetUp($app) 'database' => ':memory:', 'prefix' => '', ]); - config()->set('resource-lock.models.User', '\Kenepa\ResourceLock\Tests\Resources\Models\User'); + config()->set('filament-resource-lock.models.User', '\Blendbyte\FilamentResourceLock\Tests\Resources\Models\User'); config()->set('app.key', '6rE9Nz59bGRbeMATftriyQjrpF7DcOQm'); - $migration = include __DIR__ . '/../database/migrations/create_resource_lock_table.php.stub'; + $migration = include __DIR__.'/../database/migrations/create_resource_lock_table.php.stub'; $migration->up(); - $migration = include __DIR__ . '/Migrations/post_migration.php'; + $migration = include __DIR__.'/Migrations/post_migration.php'; $migration->up(); - $migration = include __DIR__ . '/Migrations/user_migration.php'; + $migration = include __DIR__.'/Migrations/user_migration.php'; $migration->up(); - view()->addLocation(__DIR__ . '/Fixtures/views'); + view()->addLocation(__DIR__.'/Fixtures/views'); } protected function getPackageProviders($app) { $providers = [ ActionsServiceProvider::class, - BladeCaptureDirectiveServiceProvider::class, BladeHeroiconsServiceProvider::class, BladeIconsServiceProvider::class, FilamentServiceProvider::class, diff --git a/tests/Unit/modelHasLockTest.php b/tests/Unit/modelHasLockTest.php index abb717f..2414f96 100644 --- a/tests/Unit/modelHasLockTest.php +++ b/tests/Unit/modelHasLockTest.php @@ -1,7 +1,12 @@ and($post->lock()) ->toBeFalse(); }); + +describe('Lock Events', function () { + it('dispatches ResourceLocked when a new lock is acquired', function () { + Event::fake(); + + $user = createUser(); + actingAs($user); + $post = createPost(); + + $post->lock(); + + Event::assertDispatched(ResourceLocked::class, function ($event) use ($post, $user) { + return $event->lockable->id === $post->id + && $event->userId === $user->id; + }); + }); + + it('does not dispatch ResourceLocked on keepalive touch', function () { + Event::fake(); + + $user = createUser(); + actingAs($user); + $post = createPost(); + + $post->lock(); + $post->refresh(); + sleep(1); + $post->lock(); // keepalive + + Event::assertDispatchedTimes(ResourceLocked::class, 1); + }); + + it('dispatches ResourceLockExpired then ResourceLocked when a new lock overwrites an expired one', function () { + Event::fake(); + + $user1 = createUser(); + $user2 = createUser(); + $post = createPost(); + createExpiredResourceLock($user1, $post); + + actingAs($user2); + $post->refresh(); + $post->lock(); + + Event::assertDispatched(ResourceLockExpired::class, function ($event) use ($post, $user1) { + return $event->lockable->id === $post->id + && $event->originalUserId === $user1->id; + }); + + Event::assertDispatched(ResourceLocked::class, function ($event) use ($post, $user2) { + return $event->lockable->id === $post->id + && $event->userId === $user2->id; + }); + }); + + it('dispatches ResourceUnlocked on natural unlock by owner', function () { + Event::fake(); + + $user = createUser(); + actingAs($user); + $post = createPost(); + $post->lock(); + $post->refresh(); + + $post->unlock(); + + Event::assertDispatched(ResourceUnlocked::class, function ($event) use ($post, $user) { + return $event->lockable->id === $post->id + && $event->userId === $user->id; + }); + Event::assertNotDispatched(ResourceLockForceUnlocked::class); + }); + + it('does not dispatch ResourceUnlocked when unlock is rejected', function () { + Event::fake(); + + $user1 = createUser(); + actingAs($user1); + $post = createPost(); + $post->lock(); + + $user2 = createUser(); + actingAs($user2); + $post->refresh(); + $post->unlock(force: false); + + Event::assertNotDispatched(ResourceUnlocked::class); + Event::assertNotDispatched(ResourceLockForceUnlocked::class); + }); + + it('dispatches ResourceLockForceUnlocked with correct user IDs on force unlock', function () { + Event::fake(); + + $user1 = createUser(); + actingAs($user1); + $post = createPost(); + $post->lock(); + + $user2 = createUser(); + actingAs($user2); + $post->refresh(); + $post->unlock(force: true); + + Event::assertDispatched(ResourceLockForceUnlocked::class, function ($event) use ($post, $user1, $user2) { + return $event->lockable->id === $post->id + && $event->originalUserId === $user1->id + && $event->actorUserId === $user2->id; + }); + Event::assertNotDispatched(ResourceUnlocked::class); + }); + + it('dispatches no events when events are disabled via config', function () { + Event::fake([ResourceLocked::class, ResourceUnlocked::class, ResourceLockExpired::class, ResourceLockForceUnlocked::class]); + config(['filament-resource-lock.events.enabled' => false]); + + $user = createUser(); + actingAs($user); + $post = createPost(); + $post->lock(); + $post->refresh(); + $post->unlock(); + + Event::assertNothingDispatched(); + }); +});