diff --git a/composer.json b/composer.json index 5ac30c0c..c3612713 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,9 @@ "mockery/mockery": "^1.5", "orchestra/testbench": "^6.0 || ^7.6 || ^8.0", "orchestra/testbench-dusk": "^6.0 || ^7.6 || ^8.0", - "pestphp/pest": "^2.0", - "pestphp/pest-plugin-laravel": "^2.0", - "pestphp/pest-plugin-mock": "^2.0", + "pestphp/pest": "^1.21 || ^2.0", + "pestphp/pest-plugin-laravel": "^1.2 || ^2.0", + "pestphp/pest-plugin-mock": "^1.0 || ^2.0", "spatie/laravel-ray": "^1.29" }, "autoload": { diff --git a/config/nova-file-manager.php b/config/nova-file-manager.php index 896c2d84..2730eae9 100644 --- a/config/nova-file-manager.php +++ b/config/nova-file-manager.php @@ -44,7 +44,7 @@ | | default: false */ - 'show_hidden_files' => env('NOVA_FILE_MANAGER_SHOW_HIDDEN_FILES', false), + 'show_hidden_files' => (bool) env('NOVA_FILE_MANAGER_SHOW_HIDDEN_FILES', false), /* |-------------------------------------------------------------------------- @@ -56,7 +56,7 @@ | | default: true */ - 'human_readable_size' => env('NOVA_FILE_MANAGER_HUMAN_READABLE_SIZE', true), + 'human_readable_size' => (bool) env('NOVA_FILE_MANAGER_HUMAN_READABLE_SIZE', true), /* |-------------------------------------------------------------------------- @@ -68,7 +68,7 @@ | | default: true */ - 'human_readable_datetime' => env('NOVA_FILE_MANAGER_HUMAN_READABLE_DATETIME', true), + 'human_readable_datetime' => (bool) env('NOVA_FILE_MANAGER_HUMAN_READABLE_DATETIME', true), /* |-------------------------------------------------------------------------- @@ -83,9 +83,9 @@ */ 'file_analysis' => [ - 'enabled' => env('NOVA_FILE_MANAGER_FILE_ANALYSIS_ENABLED', true), + 'enabled' => (bool) env('NOVA_FILE_MANAGER_FILE_ANALYSIS_ENABLED', true), 'cache' => [ - 'enabled' => env('NOVA_FILE_MANAGER_FILE_ANALYSIS_CACHE_ENABLED', true), + 'enabled' => (bool) env('NOVA_FILE_MANAGER_FILE_ANALYSIS_CACHE_ENABLED', true), 'ttl_in_seconds' => env('NOVA_FILE_MANAGER_FILE_ANALYSIS_CACHE_TTL_IN_SECONDS', 60 * 60 * 24), ], ], @@ -119,7 +119,7 @@ | Uses: Storage::temporaryUrl() */ 'url_signing' => [ - 'enabled' => env('NOVA_FILE_MANAGER_URL_SIGNING_ENABLED', false), + 'enabled' => (bool) env('NOVA_FILE_MANAGER_URL_SIGNING_ENABLED', false), 'unit' => 'minutes', 'value' => 10, ], @@ -135,8 +135,8 @@ | */ 'update_checker' => [ - 'enabled' => env('NOVA_FILE_MANAGER_UPDATE_CHECKER_ENABLED', true), - 'ttl_in_days' => env('NOVA_FILE_MANAGER_UPDATE_CHECKER_TTL_IN_DAYS', 1), + 'enabled' => (bool) env('NOVA_FILE_MANAGER_UPDATE_CHECKER_ENABLED', true), + 'ttl_in_days' => (int) env('NOVA_FILE_MANAGER_UPDATE_CHECKER_TTL_IN_DAYS', 1), ], /* @@ -151,7 +151,7 @@ | */ 'tour' => [ - 'enabled' => env('NOVA_FILE_MANAGER_TOUR_ENABLED', true), + 'enabled' => (bool) env('NOVA_FILE_MANAGER_TOUR_ENABLED', true), ], /* @@ -162,7 +162,7 @@ | Enable Pintura editor by PQINA. | */ - 'use_pintura' => env('NOVA_FILE_MANAGER_USE_PINTURA', false), + 'use_pintura' => (bool) env('NOVA_FILE_MANAGER_USE_PINTURA', false), /* |-------------------------------------------------------------------------- @@ -175,4 +175,17 @@ */ 'path' => '/nova-file-manager', + + /* + |-------------------------------------------------------------------------- + | Upload replace existing + |-------------------------------------------------------------------------- + | + | Toggle whether an upload with an existing file name should replace + | the existing file or not. + | + */ + + 'upload_replace_existing' => (bool) env('NOVA_FILE_MANAGER_UPLOAD_REPLACE_EXISTING', false), + ]; diff --git a/docs/access-control.md b/docs/access-control.md index c059cad8..a05944e7 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -245,3 +245,31 @@ class Project extends Resource } ``` +## Upload with an existing file name + +By default, when you upload a file at a path which already contains a file with the same name a validation error will be thrown. You may want the file to be replaced by the upload. You can change the configuration to always replace existing file in the [Configuration file](/configuration#upload-replace-existing). You can also do it programmatically by using the `uploadReplaceExisting` method on your tool, field or wrapper. + +```php +// app/Nova/Project.php + +use Oneduo\NovaFileManager\FileManager; +use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Support\Facades\Storage; +use Laravel\Nova\Http\Requests\NovaRequest; + +class Project extends Resource +{ + // ... + + public function fields(NovaRequest $request): array + { + return [ + // ... any other fields + FileManager::make(__('Attachments'), 'attachments') + ->uploadReplaceExisting(function (NovaRequest $request): bool { + return true; + }), + ]; + } +} +``` diff --git a/docs/configuration.md b/docs/configuration.md index 7b6b0b7b..f1d8ef9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -135,3 +135,21 @@ Toggles whether to show use Pintura image editor ::: tip NOTE You can find details about the Pintura integration in the [Pintura image editor section](/pintura). ::: + +## `path` + +This is the URI path where File Manager will be accessible from + +| Type | Default | +|----------|----------------------| +| `string` | `/nova-file-manager` | + + +## `upload_replace_existing` + +Toggle whether an upload with an existing file name should replace the existing file or not + +| Type | Default | +|--------|---------| +| `bool` | `false` | + diff --git a/docs/tool.md b/docs/tool.md index 73482ad0..3bff2d60 100644 --- a/docs/tool.md +++ b/docs/tool.md @@ -36,6 +36,10 @@ If you are using a custom menu, you may want to add a new entry to it. MenuSection::make('File Manager')->path('/nova-file-manager') ``` +::: tip NOTE +You can change the tool path in the [Configuration file](/configuration#path). +::: + ## Navigating in the tool tool diff --git a/src/Contracts/Support/InteractsWithFilesystem.php b/src/Contracts/Support/InteractsWithFilesystem.php index d57bd5a3..74bf6b41 100644 --- a/src/Contracts/Support/InteractsWithFilesystem.php +++ b/src/Contracts/Support/InteractsWithFilesystem.php @@ -67,6 +67,10 @@ public function canUploadFile(Closure $callback): static; public function resolveCanUploadFile(NovaRequest $request): bool; + public function uploadReplaceExisting(Closure $callback): static; + + public function resolveUploadReplaceExisting(NovaRequest $request): bool; + public function canRenameFile(Closure $callback): static; public function resolveCanRenameFile(NovaRequest $request): bool; diff --git a/src/Http/Requests/UploadFileRequest.php b/src/Http/Requests/UploadFileRequest.php index 028eec93..053911c5 100644 --- a/src/Http/Requests/UploadFileRequest.php +++ b/src/Http/Requests/UploadFileRequest.php @@ -43,11 +43,19 @@ public function authorizationActionAttribute(string $class = null): string public function rules(): array { + $uploadReplaceExisting = $this->element()->resolveUploadReplaceExisting($this); + + $rules = ['required', 'file']; + + if (!$uploadReplaceExisting) { + $rules[] = new FileMissingInFilesystem($this); + } + return [ 'disk' => ['sometimes', 'string', new DiskExistsRule()], 'path' => ['required', 'string'], 'file' => array_merge( - ['required', 'file', new FileMissingInFilesystem($this)], + $rules, $this->element()->getUploadRules(), ), ]; diff --git a/src/Traits/Support/InteractsWithFilesystem.php b/src/Traits/Support/InteractsWithFilesystem.php index ade026a1..40b46d1e 100644 --- a/src/Traits/Support/InteractsWithFilesystem.php +++ b/src/Traits/Support/InteractsWithFilesystem.php @@ -24,6 +24,8 @@ trait InteractsWithFilesystem public ?Closure $canUploadFile = null; + public ?Closure $uploadReplaceExisting = null; + public ?Closure $canRenameFile = null; public ?Closure $canDeleteFile = null; @@ -241,6 +243,20 @@ public function resolveCanUploadFile(NovaRequest $request): bool : $this->shouldShowUploadFile($request); } + public function uploadReplaceExisting(Closure $callback): static + { + $this->uploadReplaceExisting = $callback; + + return $this; + } + + public function resolveUploadReplaceExisting(NovaRequest $request): bool + { + return is_callable($this->uploadReplaceExisting) + ? call_user_func($this->uploadReplaceExisting, $request) + : config('nova-file-manager.upload_replace_existing', false); + } + public function canRenameFile(Closure $callback): static { $this->canRenameFile = $callback; diff --git a/tests/Feature/File/FileTest.php b/tests/Feature/File/FileTest.php index 5917fa6a..43ba590c 100644 --- a/tests/Feature/File/FileTest.php +++ b/tests/Feature/File/FileTest.php @@ -125,6 +125,180 @@ ]); }); +it('cannot upload a file with an existing file name when the upload_replace_existing is false', function () { + Event::fake(); + + Nova::$tools = [ + NovaFileManager::make(), + ]; + + config()->set('nova-file-manager.upload_replace_existing', false); + + Storage::disk($this->disk)->put($first = 'first.txt', Str::random()); + + postJson( + uri: route('nova-file-manager.files.upload'), + data: [ + 'disk' => $this->disk, + 'path' => '/', + 'file' => UploadedFile::fake()->create($first), + 'resumableFilename' => $first, + ], + ) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'file' => [ + __('nova-file-manager::validation.path.exists', ['path' => "/{$first}"]), + ], + ]); + + Event::assertNotDispatched( + event: FileUploaded::class, + callback: function (FileUploaded $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === "/{$first}"; + }, + ); +}); + +it('cannot upload a file with an existing file name when the upload_replace_existing is programmatically false', function () { + Event::fake(); + + Nova::$tools = [ + NovaFileManager::make() + ->uploadReplaceExisting(fn() => false), + ]; + + config()->set('nova-file-manager.upload_replace_existing', true); + + Storage::disk($this->disk)->put($first = 'first.txt', Str::random()); + + postJson( + uri: route('nova-file-manager.files.upload'), + data: [ + 'disk' => $this->disk, + 'path' => '/', + 'file' => UploadedFile::fake()->create($first), + 'resumableFilename' => $first, + ], + ) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'file' => [ + __('nova-file-manager::validation.path.exists', ['path' => "/{$first}"]), + ], + ]); + + Event::assertNotDispatched( + event: FileUploaded::class, + callback: function (FileUploaded $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === "/{$first}"; + }, + ); +}); + +it('can upload a file with an existing file name when the upload_replace_existing is true', function () { + Event::fake(); + + Nova::$tools = [ + NovaFileManager::make(), + ]; + + config()->set('nova-file-manager.upload_replace_existing', true); + + Storage::disk($this->disk)->put($first = 'first.txt', "first"); + + postJson( + uri: route('nova-file-manager.files.upload'), + data: [ + 'disk' => $this->disk, + 'path' => '/', + 'file' => UploadedFile::fake()->createWithContent($first, "second"), + 'resumableFilename' => $first, + ], + ) + ->assertOk() + ->assertJson([ + 'message' => __('nova-file-manager::messages.file.upload'), + ]); + + Storage::disk($this->disk)->assertExists($first); + + Event::assertDispatched( + event: FileUploading::class, + callback: function (FileUploading $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === $first; + }, + ); + + Event::assertDispatched( + event: FileUploaded::class, + callback: function (FileUploaded $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === $first; + }, + ); + + expect(Storage::disk($this->disk)->get($first)) + ->toBe('second'); +}); + +it('can upload a file with an existing file name when the upload_replace_existing is programmatically true', function () { + Event::fake(); + + Nova::$tools = [ + NovaFileManager::make() + ->uploadReplaceExisting(fn() => true), + ]; + + config()->set('nova-file-manager.upload_replace_existing', false); + + Storage::disk($this->disk)->put($first = 'first.txt', "first"); + + postJson( + uri: route('nova-file-manager.files.upload'), + data: [ + 'disk' => $this->disk, + 'path' => '/', + 'file' => UploadedFile::fake()->createWithContent($first, "second"), + 'resumableFilename' => $first, + ], + ) + ->assertOk() + ->assertJson([ + 'message' => __('nova-file-manager::messages.file.upload'), + ]); + + Storage::disk($this->disk)->assertExists($first); + + Event::assertDispatched( + event: FileUploading::class, + callback: function (FileUploading $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === $first; + }, + ); + + Event::assertDispatched( + event: FileUploaded::class, + callback: function (FileUploaded $event) use ($first) { + return $event->filesystem === Storage::disk($this->disk) + && $event->disk === $this->disk + && $event->path === $first; + }, + ); + + expect(Storage::disk($this->disk)->get($first)) + ->toBe('second'); +}); + it('can rename a file', function () { Event::fake();