diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 45054107d2..f741bf1633 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -2,6 +2,7 @@ name: PHP Tests on: push: + pull_request: schedule: - cron: '0 0 * * *' diff --git a/.gitignore b/.gitignore index 278ed279b9..19dafafeac 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ composer.lock .phpunit.result.cache src/public/packages/ /.phpunit.cache +coverage/ diff --git a/composer.json b/composer.json index 8cc224623b..e1d1ec05fd 100644 --- a/composer.json +++ b/composer.json @@ -62,9 +62,12 @@ ] }, "scripts": { - "test": "vendor/bin/phpunit --testdox", + "test": [ + "@putenv XDEBUG_MODE=off", + "vendor/bin/phpunit" + ], "test-failing": "vendor/bin/phpunit --order-by=defects --stop-on-failure", - "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text" + "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage" }, "extra": { "branch-alias": { diff --git a/phpunit.xml b/phpunit.xml index 3c6e23bad0..d13d723465 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ ./src/app/Library/CrudPanel/Traits/ ./src/app/Library/Validation/ + ./src/app/Library/Uploaders/ ./src/app/Library/CrudPanel/ ./src/app/Models/Traits/ ./src/app/Library/Widget.php @@ -35,9 +36,11 @@ + + diff --git a/src/app/Http/Controllers/Operations/ListOperation.php b/src/app/Http/Controllers/Operations/ListOperation.php index f9ea830507..b7e2606232 100644 --- a/src/app/Http/Controllers/Operations/ListOperation.php +++ b/src/app/Http/Controllers/Operations/ListOperation.php @@ -45,6 +45,7 @@ protected function setupListDefaults() $this->crud->operation('list', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); + $this->crud->setOperationSetting('datatablesUrl', $this->crud->getRoute()); }); } diff --git a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php index c93e0fba4d..0c458f9e09 100644 --- a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php +++ b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php @@ -281,6 +281,8 @@ protected function makeSureSubfieldsHaveNecessaryAttributes($field) $subfield['name'] = Str::replace(' ', '', $subfield['name']); $subfield['parentFieldName'] = $field['name']; + $subfield['baseFieldName'] = is_array($subfield['name']) ? implode(',', $subfield['name']) : $subfield['name']; + $subfield['baseFieldName'] = Str::afterLast($subfield['baseFieldName'], '.'); if (! isset($field['model'])) { // we're inside a simple 'repeatable' with no model/relationship, so @@ -294,8 +296,6 @@ protected function makeSureSubfieldsHaveNecessaryAttributes($field) $currentEntity = $subfield['baseEntity'] ?? $field['entity']; $subfield['baseModel'] = $subfield['baseModel'] ?? $field['model']; $subfield['baseEntity'] = isset($field['baseEntity']) ? $field['baseEntity'].'.'.$currentEntity : $currentEntity; - $subfield['baseFieldName'] = is_array($subfield['name']) ? implode(',', $subfield['name']) : $subfield['name']; - $subfield['baseFieldName'] = Str::afterLast($subfield['baseFieldName'], '.'); } $field['subfields'][$key] = $this->makeSureFieldHasNecessaryAttributes($subfield); diff --git a/src/app/Library/Uploaders/MultipleFiles.php b/src/app/Library/Uploaders/MultipleFiles.php index e77ca33a8a..010d5c2aae 100644 --- a/src/app/Library/Uploaders/MultipleFiles.php +++ b/src/app/Library/Uploaders/MultipleFiles.php @@ -22,7 +22,7 @@ public function uploadFiles(Model $entry, $value = null) } $filesToDelete = $this->getFilesToDeleteFromRequest(); - $value = $value ?? collect(CRUD::getRequest()->file($this->getNameForRequest()))->flatten()->toArray(); + $value = $value ?? collect($value)->flatten()->toArray(); $previousFiles = $this->getPreviousFiles($entry) ?? []; if (is_array($previousFiles) && empty($previousFiles[0] ?? [])) { @@ -57,9 +57,16 @@ public function uploadFiles(Model $entry, $value = null) } } - return isset($entry->getCasts()[$this->getName()]) ? $previousFiles : json_encode($previousFiles); + $previousFiles = array_values($previousFiles); + + if (empty($previousFiles)) { + return null; + } + + return isset($entry->getCasts()[$this->getName()]) || $this->isFake() ? $previousFiles : json_encode($previousFiles); } + /** @codeCoverageIgnore */ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry = null) { $fileOrder = $this->getFileOrderFromRequest(); @@ -73,11 +80,26 @@ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry } } } + // create a temporary variable that we can unset keys + // everytime one is found. That way we avoid iterating + // already handled keys (notice we do a deep array copy) + $tempFileOrder = array_map(function ($item) { + return $item; + }, $fileOrder); foreach ($previousRepeatableValues as $previousRow => $previousFiles) { foreach ($previousFiles ?? [] as $key => $file) { - $key = array_search($file, $fileOrder, true); - if ($key === false) { + $previousFileInArray = array_filter($tempFileOrder, function ($items, $key) use ($file, $tempFileOrder) { + $found = array_search($file, $items ?? [], true); + if ($found !== false) { + Arr::forget($tempFileOrder, $key.'.'.$found); + + return true; + } + + return false; + }, ARRAY_FILTER_USE_BOTH); + if ($file && ! $previousFileInArray) { Storage::disk($this->getDisk())->delete($file); } } @@ -86,12 +108,12 @@ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry return $fileOrder; } - protected function hasDeletedFiles($value): bool + public function hasDeletedFiles($value): bool { return empty($this->getFilesToDeleteFromRequest()) ? false : true; } - protected function getEntryAttributeValue(Model $entry) + public function getEntryAttributeValue(Model $entry) { $value = $entry->{$this->getAttributeName()}; diff --git a/src/app/Library/Uploaders/SingleBase64Image.php b/src/app/Library/Uploaders/SingleBase64Image.php index 97c4e27488..71ffde3d73 100644 --- a/src/app/Library/Uploaders/SingleBase64Image.php +++ b/src/app/Library/Uploaders/SingleBase64Image.php @@ -7,11 +7,11 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +/** @codeCoverageIgnore */ class SingleBase64Image extends Uploader { public function uploadFiles(Model $entry, $value = null) { - $value = $value ?? CRUD::getRequest()->get($this->getName()); $previousImage = $this->getPreviousFiles($entry); if (! $value && $previousImage) { @@ -51,7 +51,7 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry } } - $imagesToDelete = array_diff($previousRepeatableValues, $values); + $imagesToDelete = array_diff(array_filter($previousRepeatableValues), $values); foreach ($imagesToDelete as $image) { Storage::disk($this->getDisk())->delete($image); @@ -60,13 +60,18 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry return $values; } - protected function shouldUploadFiles($value): bool + public function shouldUploadFiles($value): bool { return $value && is_string($value) && Str::startsWith($value, 'data:image'); } - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool { return $entry->exists && is_string($entryValue) && ! Str::startsWith($entryValue, 'data:image'); } + + public function getUploadedFilesFromRequest() + { + return CRUD::getRequest()->get($this->getNameForRequest()); + } } diff --git a/src/app/Library/Uploaders/SingleFile.php b/src/app/Library/Uploaders/SingleFile.php index 7a3f1318d9..0b4acf6055 100644 --- a/src/app/Library/Uploaders/SingleFile.php +++ b/src/app/Library/Uploaders/SingleFile.php @@ -10,7 +10,6 @@ class SingleFile extends Uploader { public function uploadFiles(Model $entry, $value = null) { - $value = $value ?? CrudPanelFacade::getRequest()->file($this->getName()); $previousFile = $this->getPreviousFiles($entry); if ($value === false && $previousFile) { @@ -38,6 +37,7 @@ public function uploadFiles(Model $entry, $value = null) return $previousFile; } + /** @codeCoverageIgnore */ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry = null) { $orderedFiles = $this->getFileOrderFromRequest(); @@ -53,9 +53,13 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry } foreach ($previousRepeatableValues as $row => $file) { - if ($file && ! isset($orderedFiles[$row])) { - $orderedFiles[$row] = null; - Storage::disk($this->getDisk())->delete($file); + if ($file) { + if (! isset($orderedFiles[$row])) { + $orderedFiles[$row] = null; + } + if (! in_array($file, $orderedFiles)) { + Storage::disk($this->getDisk())->delete($file); + } } } @@ -65,17 +69,17 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry /** * Single file uploaders send no value when they are not dirty. */ - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool { return is_string($entryValue); } - protected function hasDeletedFiles($entryValue): bool + public function hasDeletedFiles($entryValue): bool { return $entryValue === null; } - protected function shouldUploadFiles($value): bool + public function shouldUploadFiles($value): bool { return is_a($value, 'Illuminate\Http\UploadedFile', true); } diff --git a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php index 185350c053..e8e432ecc6 100644 --- a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php +++ b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php @@ -15,10 +15,14 @@ public static function for(array $field, array $configuration): UploaderInterfac /** * Default implementation functions. */ + + // method called on `saving` event to store and update the entry with the uploaded files public function storeUploadedFiles(Model $entry); + // method called on `retrieved` event to populated the uploaded files in the entry public function retrieveUploadedFiles(Model $entry); + // method called on `deleting` event to delete the uploaded files public function deleteUploadedFiles(Model $entry); /** @@ -30,6 +34,8 @@ public function repeats(string $repeatableContainerName): self; public function relationship(bool $isRelation): self; + public function fake(bool|string $isFake): self; + /** * Getters. */ @@ -53,11 +59,20 @@ public function getIdentifier(): string; public function getNameForRequest(): string; - public function shouldDeleteFiles(): bool; - public function canHandleMultipleFiles(): bool; public function isRelationship(): bool; public function getPreviousFiles(Model $entry): mixed; + + /** + * Strategy methods. + */ + public function shouldDeleteFiles(): bool; + + public function hasDeletedFiles($entryValue): bool; + + public function shouldUploadFiles(mixed $value): bool; + + public function shouldKeepPreviousValueUnchanged(Model $entry, mixed $entryValue): bool; } diff --git a/src/app/Library/Uploaders/Support/RegisterUploadEvents.php b/src/app/Library/Uploaders/Support/RegisterUploadEvents.php index 237f90ce85..3c0d7a72e1 100644 --- a/src/app/Library/Uploaders/Support/RegisterUploadEvents.php +++ b/src/app/Library/Uploaders/Support/RegisterUploadEvents.php @@ -60,6 +60,7 @@ private function registerSubfieldEvent(array $subfield, bool $registerModelEvent $uploader = $this->getUploader($subfield, $this->uploaderConfiguration); $crudObject = $this->crudObject->getAttributes(); $uploader = $uploader->repeats($crudObject['name']); + $uploader = $uploader->fake((isset($crudObject['fake']) && $crudObject['fake']) ? ($crudObject['store_in'] ?? 'extras') : false); // If this uploader is already registered bail out. We may endup here multiple times when doing modifications to the crud object. // Changing `subfields` properties will call the macros again. We prevent duplicate entries by checking @@ -139,6 +140,14 @@ private function setupModelEvents(string $model, UploaderInterface $uploader): v $uploader->deleteUploadedFiles($entry); }); + // if the uploader is a relationship and handles repeatable files, we will also register the deleting event on the + // parent model. that way we can control the deletion of the files when the parent model is deleted. + if ($uploader->isRelationship() && $uploader->handleRepeatableFiles) { + app('crud')->model::deleting(function ($entry) use ($uploader) { + $uploader->deleteUploadedFiles($entry); + }); + } + app('UploadersRepository')->markAsHandled($uploader->getIdentifier()); } @@ -154,9 +163,13 @@ private function setupModelEvents(string $model, UploaderInterface $uploader): v */ private function getUploader(array $crudObject, array $uploaderConfiguration): UploaderInterface { - $customUploader = isset($uploaderConfiguration['uploader']) && class_exists($uploaderConfiguration['uploader']); + $hasCustomUploader = isset($uploaderConfiguration['uploader']); + + if ($hasCustomUploader && ! is_a($uploaderConfiguration['uploader'], UploaderInterface::class, true)) { + throw new Exception('Invalid uploader class provided for '.$this->crudObjectType.' type: '.$crudObject['type']); + } - if ($customUploader) { + if ($hasCustomUploader) { return $uploaderConfiguration['uploader']::for($crudObject, $uploaderConfiguration); } diff --git a/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php b/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php index 56f6b2f91b..858a519be1 100644 --- a/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php +++ b/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php @@ -5,11 +5,15 @@ use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +/** + * @codeCoverageIgnore + */ trait HandleRepeatableUploads { public bool $handleRepeatableFiles = false; @@ -45,15 +49,46 @@ protected function uploadRepeatableFiles($values, $previousValues, $entry = null protected function handleRepeatableFiles(Model $entry): Model { - if ($this->isRelationship) { - return $this->processRelationshipRepeatableUploaders($entry); - } - $values = collect(CRUD::getRequest()->get($this->getRepeatableContainerName())); $files = collect(CRUD::getRequest()->file($this->getRepeatableContainerName())); + $value = $this->mergeValuesRecursive($values, $files); - $entry->{$this->getRepeatableContainerName()} = json_encode($this->processRepeatableUploads($entry, $value)); + if ($this->isRelationship()) { + if ($value->isEmpty()) { + return $entry; + } + + return $this->processRelationshipRepeatableUploaders($entry); + } + + $processedEntryValues = $this->processRepeatableUploads($entry, $value)->toArray(); + + if ($this->isFake()) { + $fakeValues = $entry->{$this->getFakeAttribute()} ?? []; + + if (is_string($fakeValues)) { + $fakeValues = json_decode($fakeValues, true); + } + + $fakeValues[$this->getRepeatableContainerName()] = empty($processedEntryValues) + ? null + : (isset($entry->getCasts()[$this->getFakeAttribute()]) + ? $processedEntryValues + : json_encode($processedEntryValues)); + + $entry->{$this->getFakeAttribute()} = isset($entry->getCasts()[$this->getFakeAttribute()]) + ? $fakeValues + : json_encode($fakeValues); + + return $entry; + } + + $entry->{$this->getRepeatableContainerName()} = empty($processedEntryValues) + ? null + : (isset($entry->getCasts()[$this->getRepeatableContainerName()]) + ? $processedEntryValues + : json_encode($processedEntryValues)); return $entry; } @@ -112,21 +147,6 @@ protected function getEntryOriginalValue(Model $entry) return $entry->getOriginal($this->getAttributeName()); } - protected function shouldUploadFiles($entryValue): bool - { - return true; - } - - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool - { - return $entry->exists && ($entryValue === null || $entryValue === [null]); - } - - protected function hasDeletedFiles($entryValue): bool - { - return $entryValue === false || $entryValue === null || $entryValue === [null]; - } - protected function processRepeatableUploads(Model $entry, Collection $values): Collection { foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $uploader) { @@ -150,6 +170,17 @@ private function retrieveRepeatableFiles(Model $entry): Model $repeatableUploaders = app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()); + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + + $values = is_string($values) ? json_decode($values, true) : $values; + + $values[$this->getAttributeName()] = isset($values[$this->getAttributeName()]) ? $this->getValueWithoutPath($values[$this->getAttributeName()]) : null; + $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $values : json_encode($values); + + return $entry; + } + $values = $entry->{$this->getRepeatableContainerName()}; $values = is_string($values) ? json_decode($values, true) : $values; $values = array_map(function ($item) use ($repeatableUploaders) { @@ -158,7 +189,7 @@ private function retrieveRepeatableFiles(Model $entry): Model } return $item; - }, $values); + }, $values ?? []); $entry->{$this->getRepeatableContainerName()} = $values; @@ -211,7 +242,14 @@ private function deleteRepeatableFiles(Model $entry): void return; } - $repeatableValues = collect($entry->{$this->getName()}); + if ($this->attachedToFakeField) { + $repeatableValues = $entry->{$this->attachedToFakeField}[$this->getRepeatableContainerName()] ?? null; + $repeatableValues = is_string($repeatableValues) ? json_decode($repeatableValues, true) : $repeatableValues; + $repeatableValues = collect($repeatableValues); + } + + $repeatableValues ??= collect($entry->{$this->getRepeatableContainerName()}); + foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $upload) { if (! $upload->shouldDeleteFiles()) { continue; @@ -273,7 +311,11 @@ protected function getFileOrderFromRequest(): array private function getPreviousRepeatableValues(Model $entry, UploaderInterface $uploader): array { - $previousValues = json_decode($entry->getOriginal($uploader->getRepeatableContainerName()), true); + $previousValues = $entry->getOriginal($uploader->getRepeatableContainerName()); + + if (! is_array($previousValues)) { + $previousValues = json_decode($previousValues, true); + } if (! empty($previousValues)) { $previousValues = array_column($previousValues, $uploader->getName()); @@ -282,70 +324,110 @@ private function getPreviousRepeatableValues(Model $entry, UploaderInterface $up return $previousValues ?? []; } - private function getValuesWithPathStripped(array|string|null $item, UploaderInterface $upload) + private function getValuesWithPathStripped(array|string|null $item, UploaderInterface $uploader) { - $uploadedValues = $item[$upload->getName()] ?? null; + $uploadedValues = $item[$uploader->getName()] ?? null; if (is_array($uploadedValues)) { - return array_map(function ($value) use ($upload) { - return Str::after($value, $upload->getPath()); + return array_map(function ($value) use ($uploader) { + return $uploader->getValueWithoutPath($value); }, $uploadedValues); } - return isset($uploadedValues) ? Str::after($uploadedValues, $upload->getPath()) : null; + return isset($uploadedValues) ? $uploader->getValueWithoutPath($uploadedValues) : null; } private function deleteRelationshipFiles(Model $entry): void { + if (! is_a($entry, Pivot::class, true) && + ! $entry->relationLoaded($this->getRepeatableContainerName()) && + method_exists($entry, $this->getRepeatableContainerName()) + ) { + $entry->loadMissing($this->getRepeatableContainerName()); + } + foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $uploader) { - $uploader->deleteRepeatableRelationFiles($entry); + if ($uploader->shouldDeleteFiles()) { + $uploader->deleteRepeatableRelationFiles($entry); + } } } - private function deleteRepeatableRelationFiles(Model $entry) + protected function deleteRepeatableRelationFiles(Model $entry) { - if (in_array($this->getRepeatableRelationType(), ['BelongsToMany', 'MorphToMany'])) { - $pivotAttributes = $entry->getAttributes(); - $connectedPivot = $entry->pivotParent->{$this->getRepeatableContainerName()}->where(function ($item) use ($pivotAttributes) { - $itemPivotAttributes = $item->pivot->only(array_keys($pivotAttributes)); + match ($this->getRepeatableRelationType()) { + 'BelongsToMany', 'MorphToMany' => $this->deletePivotFiles($entry), + default => $this->deleteRelatedFiles($entry), + }; + } - return $itemPivotAttributes === $pivotAttributes; - })->first(); + private function deleteRelatedFiles(Model $entry) + { + if (get_class($entry) === get_class(app('crud')->model)) { + $relatedEntries = $entry->{$this->getRepeatableContainerName()} ?? []; + } - if (! $connectedPivot) { - return; - } + if (! is_a($relatedEntries ?? '', Collection::class, true)) { + $relatedEntries = ! empty($relatedEntries) ? [$relatedEntries] : [$entry]; + } - $files = $connectedPivot->getOriginal()['pivot_'.$this->getAttributeName()]; + foreach ($relatedEntries as $relatedEntry) { + $this->deleteFiles($relatedEntry); + } + } - if (! $files) { - return; + protected function deletePivotFiles(Pivot|Model $entry) + { + if (! is_a($entry, Pivot::class, true)) { + $pivots = $entry->{$this->getRepeatableContainerName()}; + foreach ($pivots as $pivot) { + $this->deletePivotModelFiles($pivot); } - if ($this->handleMultipleFiles && is_string($files)) { - try { - $files = json_decode($files, true); - } catch (\Exception) { - Log::error('Could not parse files for deletion pivot entry with key: '.$entry->getKey().' and uploader: '.$this->getName()); + return; + } + + $pivotAttributes = $entry->getAttributes(); + $connectedPivot = $entry->pivotParent->{$this->getRepeatableContainerName()}->where(function ($item) use ($pivotAttributes) { + $itemPivotAttributes = $item->pivot->only(array_keys($pivotAttributes)); - return; - } - } + return $itemPivotAttributes === $pivotAttributes; + })->first(); - if (is_array($files)) { - foreach ($files as $value) { - $value = Str::start($value, $this->getPath()); - Storage::disk($this->getDisk())->delete($value); - } + if (! $connectedPivot) { + return; + } + + $this->deletePivotModelFiles($connectedPivot); + } + + private function deletePivotModelFiles(Pivot|Model $entry) + { + $files = $entry->getOriginal()['pivot_'.$this->getAttributeName()]; + + if (! $files) { + return; + } + + if ($this->handleMultipleFiles && is_string($files)) { + try { + $files = json_decode($files, true); + } catch (\Exception) { + Log::error('Could not parse files for deletion pivot entry with key: '.$entry->getKey().' and uploader: '.$this->getName()); return; } + } - $value = Str::start($files, $this->getPath()); - Storage::disk($this->getDisk())->delete($value); + if (is_array($files)) { + foreach ($files as $value) { + $value = Str::start($value, $this->getPath()); + Storage::disk($this->getDisk())->delete($value); + } return; } - $this->deleteFiles($entry); + $value = Str::start($files, $this->getPath()); + Storage::disk($this->getDisk())->delete($value); } } diff --git a/src/app/Library/Uploaders/Support/UploadersRepository.php b/src/app/Library/Uploaders/Support/UploadersRepository.php index a751820332..a2da49ea7e 100644 --- a/src/app/Library/Uploaders/Support/UploadersRepository.php +++ b/src/app/Library/Uploaders/Support/UploadersRepository.php @@ -2,7 +2,10 @@ namespace Backpack\CRUD\app\Library\Uploaders\Support; +use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; final class UploadersRepository { @@ -23,7 +26,7 @@ final class UploadersRepository public function __construct() { - $this->uploaderClasses = config('backpack.crud.uploaders'); + $this->uploaderClasses = config('backpack.crud.uploaders', []); } /** @@ -49,7 +52,7 @@ public function isUploadHandled(string $objectName): bool */ public function hasUploadFor(string $objectType, string $group): bool { - return array_key_exists($objectType, $this->uploaderClasses[$group]); + return array_key_exists($objectType, $this->uploaderClasses[$group] ?? []); } /** @@ -57,14 +60,32 @@ public function hasUploadFor(string $objectType, string $group): bool */ public function getUploadFor(string $objectType, string $group): string { + if (! $this->hasUploadFor($objectType, $group)) { + throw new \Exception('There is no uploader defined for the given field type.'); + } + return $this->uploaderClasses[$group][$objectType]; } + /** + * return the registered groups names AKA macros. eg: withFiles, withMedia. + */ + public function getUploadersGroupsNames(): array + { + return array_keys($this->uploaderClasses); + } + /** * Register new uploaders or override existing ones. */ public function addUploaderClasses(array $uploaders, string $group): void { + // ensure all uploaders implement the UploaderInterface + foreach ($uploaders as $uploader) { + if (! is_a($uploader, UploaderInterface::class, true)) { + throw new \Exception('The uploader class must implement the UploaderInterface.'); + } + } $this->uploaderClasses[$group] = array_merge($this->getGroupUploadersClasses($group), $uploaders); } @@ -119,4 +140,90 @@ public function getRegisteredUploadNames(string $uploadName): array return $uploader->getName(); }, $this->getRepeatableUploadersFor($uploadName)); } + + /** + * Get the uploaders classes for the given group of uploaders. + */ + public function getAjaxUploadTypes(string $uploaderMacro = 'withFiles'): array + { + $ajaxFieldTypes = []; + foreach ($this->uploaderClasses[$uploaderMacro] as $fieldType => $uploader) { + if (is_a($uploader, 'Backpack\Pro\Uploads\BackpackAjaxUploader', true)) { + $ajaxFieldTypes[] = $fieldType; + } + } + + return $ajaxFieldTypes; + } + + /** + * Get an ajax uploader instance for a given input name. + */ + public function getFieldUploaderInstance(string $requestInputName): UploaderInterface + { + if (strpos($requestInputName, '#') !== false) { + $repeatableContainerName = Str::before($requestInputName, '#'); + $requestInputName = Str::after($requestInputName, '#'); + + $uploaders = $this->getRepeatableUploadersFor($repeatableContainerName); + + $uploader = Arr::first($uploaders, function ($uploader) use ($requestInputName) { + return $uploader->getName() === $requestInputName; + }); + + if (! $uploader) { + abort(500, 'Could not find the field in the repeatable uploaders.'); + } + + return $uploader; + } + + if (empty($crudObject = CRUD::fields()[$requestInputName] ?? [])) { + abort(500, 'Could not find the field in the CRUD fields.'); + } + + if (! $uploaderMacro = $this->getUploadCrudObjectMacroType($crudObject)) { + abort(500, 'There is no uploader defined for the given field type.'); + } + + if (! $this->isValidUploadField($crudObject, $uploaderMacro)) { + abort(500, 'Invalid field for upload.'); + } + + $uploaderConfiguration = $crudObject[$uploaderMacro] ?? []; + $uploaderConfiguration = ! is_array($uploaderConfiguration) ? [] : $uploaderConfiguration; + $uploaderClass = $this->getUploadFor($crudObject['type'], $uploaderMacro); + + return new $uploaderClass(['name' => $requestInputName], $uploaderConfiguration); + } + + /** + * Get the upload field macro type for the given object. + */ + private function getUploadCrudObjectMacroType(array $crudObject): string|null + { + $uploadersGroups = $this->getUploadersGroupsNames(); + + foreach ($uploadersGroups as $uploaderMacro) { + if (isset($crudObject[$uploaderMacro])) { + return $uploaderMacro; + } + } + + return null; + } + + private function isValidUploadField($crudObject, $uploaderMacro) + { + if (Str::contains($crudObject['name'], '#')) { + $container = Str::before($crudObject['name'], '#'); + $field = array_filter(CRUD::fields()[$container]['subfields'] ?? [], function ($item) use ($crudObject, $uploaderMacro) { + return $item['name'] === $crudObject['name'] && in_array($item['type'], $this->getAjaxUploadTypes($uploaderMacro)); + }); + + return ! empty($field); + } + + return in_array($crudObject['type'], $this->getAjaxUploadTypes($uploaderMacro)); + } } diff --git a/src/app/Library/Uploaders/Uploader.php b/src/app/Library/Uploaders/Uploader.php index eb298a89f6..a11295aeb7 100644 --- a/src/app/Library/Uploaders/Uploader.php +++ b/src/app/Library/Uploaders/Uploader.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Library\Uploaders; +use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; use Backpack\CRUD\app\Library\Uploaders\Support\Traits\HandleFileNaming; use Backpack\CRUD\app\Library\Uploaders\Support\Traits\HandleRepeatableUploads; @@ -21,7 +22,7 @@ abstract class Uploader implements UploaderInterface private string $path = ''; - private bool $handleMultipleFiles = false; + public bool $handleMultipleFiles = false; private bool $deleteWhenEntryIsDeleted = true; @@ -76,17 +77,19 @@ public function storeUploadedFiles(Model $entry): Model return $this->handleRepeatableFiles($entry); } + $values = $this->getUploadedFilesFromRequest(); + if ($this->attachedToFakeField) { $fakeFieldValue = $entry->{$this->attachedToFakeField}; $fakeFieldValue = is_string($fakeFieldValue) ? json_decode($fakeFieldValue, true) : (array) $fakeFieldValue; - $fakeFieldValue[$this->getAttributeName()] = $this->uploadFiles($entry); + $fakeFieldValue[$this->getAttributeName()] = $this->uploadFiles($entry, $values); $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $fakeFieldValue : json_encode($fakeFieldValue); return $entry; } - $entry->{$this->getAttributeName()} = $this->uploadFiles($entry); + $entry->{$this->getAttributeName()} = $this->uploadFiles($entry, $values); return $entry; } @@ -102,16 +105,14 @@ public function retrieveUploadedFiles(Model $entry): Model public function deleteUploadedFiles(Model $entry): void { - if ($this->deleteWhenEntryIsDeleted) { - if (! in_array(SoftDeletes::class, class_uses_recursive($entry), true)) { - $this->performFileDeletion($entry); + if (! in_array(SoftDeletes::class, class_uses_recursive($entry), true)) { + $this->performFileDeletion($entry); - return; - } + return; + } - if ($entry->isForceDeleting() === true) { - $this->performFileDeletion($entry); - } + if ($entry->isForceDeleting() === true) { + $this->performFileDeletion($entry); } } @@ -153,6 +154,21 @@ public function shouldDeleteFiles(): bool return $this->deleteWhenEntryIsDeleted; } + public function shouldUploadFiles($entryValue): bool + { + return true; + } + + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + { + return $entry->exists && ($entryValue === null || $entryValue === [null]); + } + + public function hasDeletedFiles($entryValue): bool + { + return $entryValue === false || $entryValue === null || $entryValue === [null]; + } + public function getIdentifier(): string { if ($this->handleRepeatableFiles) { @@ -182,13 +198,32 @@ public function getPreviousFiles(Model $entry): mixed if (! $this->attachedToFakeField) { return $this->getOriginalValue($entry); } - $value = $this->getOriginalValue($entry, $this->attachedToFakeField); $value = is_string($value) ? json_decode($value, true) : (array) $value; return $value[$this->getAttributeName()] ?? null; } + public function getValueWithoutPath(?string $value = null): ?string + { + return $value ? Str::after($value, $this->path) : null; + } + + public function getUploadedFilesFromRequest() + { + return CRUD::getRequest()->file($this->getNameForRequest()); + } + + public function isFake(): bool + { + return $this->attachedToFakeField !== false; + } + + public function getFakeAttribute(): bool|string + { + return $this->attachedToFakeField; + } + /******************************* * Setters - fluently configure the uploader *******************************/ @@ -206,6 +241,13 @@ public function relationship(bool $isRelationship): self return $this; } + public function fake(bool|string $isFake): self + { + $this->attachedToFakeField = $isFake; + + return $this; + } + /******************************* * Default implementation functions *******************************/ @@ -217,32 +259,44 @@ private function retrieveFiles(Model $entry): Model { $value = $entry->{$this->getAttributeName()}; - if ($this->handleMultipleFiles) { - if (! isset($entry->getCasts()[$this->getName()]) && is_string($value)) { - $entry->{$this->getAttributeName()} = json_decode($value, true); - } + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + + $values = is_string($values) ? json_decode($values, true) : $values; + $attributeValue = $values[$this->getAttributeName()] ?? null; + $attributeValue = is_array($attributeValue) ? array_map(fn ($value) => $this->getValueWithoutPath($value), $attributeValue) : $this->getValueWithoutPath($attributeValue); + $values[$this->getAttributeName()] = $attributeValue; + $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $values : json_encode($values); return $entry; } - if ($this->attachedToFakeField) { - $values = $entry->{$this->attachedToFakeField}; - $values = is_string($values) ? json_decode($values, true) : (array) $values; - - $values[$this->getAttributeName()] = isset($values[$this->getAttributeName()]) ? Str::after($values[$this->getAttributeName()], $this->path) : null; - $entry->{$this->attachedToFakeField} = json_encode($values); + if ($this->handleMultipleFiles) { + if (! isset($entry->getCasts()[$this->getName()]) && is_string($value)) { + $entry->{$this->getAttributeName()} = json_decode($value, true); + } return $entry; } - $entry->{$this->getAttributeName()} = Str::after($value, $this->path); + $entry->{$this->getAttributeName()} = $this->getValueWithoutPath($value); return $entry; } - private function deleteFiles(Model $entry) + protected function deleteFiles(Model $entry) { - $values = $entry->{$this->getAttributeName()}; + if (! $this->shouldDeleteFiles()) { + return; + } + + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + $values = is_string($values) ? json_decode($values, true) : $values; + $values = $values[$this->getAttributeName()] ?? null; + } + + $values ??= $entry->{$this->getAttributeName()}; if ($values === null) { return; @@ -250,7 +304,7 @@ private function deleteFiles(Model $entry) if ($this->handleMultipleFiles) { // ensure we have an array of values when field is not casted in model. - if (! isset($entry->getCasts()[$this->name]) && is_string($values)) { + if (is_string($values)) { $values = json_decode($values, true); } foreach ($values ?? [] as $value) { @@ -267,7 +321,7 @@ private function deleteFiles(Model $entry) private function performFileDeletion(Model $entry) { - if (! $this->handleRepeatableFiles) { + if (! $this->handleRepeatableFiles && $this->deleteWhenEntryIsDeleted) { $this->deleteFiles($entry); return; @@ -277,7 +331,7 @@ private function performFileDeletion(Model $entry) } /******************************* - * Private helper methods + * helper methods *******************************/ private function getPathFromConfiguration(array $crudObject, array $configuration): string { diff --git a/src/app/Library/Validation/Rules/BackpackCustomRule.php b/src/app/Library/Validation/Rules/BackpackCustomRule.php index 5a51c4cdad..3ba76f0678 100644 --- a/src/app/Library/Validation/Rules/BackpackCustomRule.php +++ b/src/app/Library/Validation/Rules/BackpackCustomRule.php @@ -2,18 +2,22 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; +use Backpack\CRUD\app\Library\Validation\Rules\Support\ValidateArrayContract; +use Backpack\Pro\Uploads\Validation\ValidGenericAjaxEndpoint; use Closure; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidatorAwareRule; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; -/** - * @method static static itemRules() - */ abstract class BackpackCustomRule implements ValidationRule, DataAwareRule, ValidatorAwareRule { + use Support\HasFiles; + /** * @var \Illuminate\Contracts\Validation\Validator */ @@ -30,6 +34,12 @@ public static function field(string|array|ValidationRule|Rule $rules = []): self $instance = new static(); $instance->fieldRules = self::getRulesAsArray($rules); + if ($instance->validatesArrays()) { + if (! in_array('array', $instance->getFieldRules())) { + $instance->fieldRules[] = 'array'; + } + } + return $instance; } @@ -43,7 +53,18 @@ public static function field(string|array|ValidationRule|Rule $rules = []): self */ public function validate(string $attribute, mixed $value, Closure $fail): void { - // is the extending class reponsability the implementation of the validation logic + $value = $this->ensureValueIsValid($value); + + if ($value === false) { + $fail('Invalid value for the attribute.')->translate(); + + return; + } + + $errors = $this->validateOnSubmit($attribute, $value); + foreach ($errors as $error) { + $fail($error)->translate(); + } } /** @@ -96,19 +117,120 @@ protected static function getRulesAsArray($rules) return $rules; } + protected function ensureValueIsValid($value) + { + if ($this->validatesArrays() && ! is_array($value)) { + try { + $value = json_decode($value, true) ?? []; + } catch(\Exception $e) { + return false; + } + } + + return $value; + } + + private function validatesArrays(): bool + { + return is_a($this, ValidateArrayContract::class); + } + + private function validateAndGetErrors(string $attribute, mixed $value, array $rules): array + { + $validator = Validator::make($value, [ + $attribute => $rules, + ], $this->validator->customMessages, $this->getValidatorCustomAttributes($attribute)); + + return $validator->errors()->messages()[$attribute] ?? (! empty($validator->errors()->messages()) ? current($validator->errors()->messages()) : []); + } + + private function getValidatorCustomAttributes(string $attribute): array + { + if (! is_a($this, ValidGenericAjaxEndpoint::class) && ! Str::contains($attribute, '.*.')) { + return $this->validator->customAttributes; + } + + // generic fallback to `profile picture` from `profile.*.picture` + return [$attribute => Str::replace('.*.', ' ', $attribute)]; + } + + protected function getValidationAttributeString(string $attribute) + { + return Str::substrCount($attribute, '.') > 1 ? + Str::before($attribute, '.').'.*.'.Str::afterLast($attribute, '.') : + $attribute; + } + + protected function validateOnSubmit(string $attribute, mixed $value): array + { + return $this->validateRules($attribute, $value); + } + + protected function validateFieldAndFile(string $attribute, null|array $data = null, array|null $customRules = null): array + { + $fieldErrors = $this->validateFieldRules($attribute, $data, $customRules); + + $fileErrors = $this->validateFileRules($attribute, $data); + + return array_merge($fieldErrors, $fileErrors); + } + /** * Implementation. */ - public function validateFieldRules(string $attribute, mixed $value, Closure $fail): void + public function validateFieldRules(string $attribute, null|array|string|UploadedFile $data = null, array|null $customRules = null): array { - $validator = Validator::make([$attribute => $value], [ - $attribute => $this->getFieldRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); + $data = $data ?? $this->data; + $validationRuleAttribute = $this->getValidationAttributeString($attribute); + $data = $this->prepareValidatorData($data, $attribute); - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } + return $this->validateAndGetErrors($validationRuleAttribute, $data, $customRules ?? $this->getFieldRules()); + } + + protected function prepareValidatorData(array|string|UploadedFile $data, string $attribute): array + { + if ($this->validatesArrays() && is_array($data) && ! Str::contains($attribute, '.')) { + return Arr::has($data, $attribute) ? $data : [$attribute => $data]; } + + if (Str::contains($attribute, '.')) { + $validData = []; + + Arr::set($validData, $attribute, ! is_array($data) ? $data : Arr::get($data, $attribute)); + + return $validData; + } + + return [$attribute => is_array($data) ? (Arr::has($data, $attribute) ? Arr::get($data, $attribute) : $data) : $data]; + } + + protected function validateFileRules(string $attribute, mixed $data): array + { + $items = $this->prepareValidatorData($data ?? $this->data, $attribute); + $items = is_array($items) ? $items : [$items]; + $validationRuleAttribute = $this->getValidationAttributeString($attribute); + + $filesToValidate = Arr::get($items, $attribute); + $filesToValidate = is_array($filesToValidate) ? array_filter($filesToValidate, function ($item) { + return $item instanceof UploadedFile; + }) : (is_a($filesToValidate, UploadedFile::class, true) ? [$filesToValidate] : []); + + Arr::set($items, $attribute, $filesToValidate); + + $errors = []; + + // validate each file individually + foreach ($filesToValidate as $key => $file) { + $fileToValidate = []; + Arr::set($fileToValidate, $attribute, $file); + $errors[] = $this->validateAndGetErrors($validationRuleAttribute, $fileToValidate, $this->getFileRules()); + } + + return array_unique(array_merge(...$errors)); + } + + public function validateRules(string $attribute, mixed $value): array + { + return $this->validateFieldAndFile($attribute, $value); } } diff --git a/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php b/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php new file mode 100644 index 0000000000..d8339a4d96 --- /dev/null +++ b/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php @@ -0,0 +1,7 @@ +validateArrayData($attribute, $fail, $value); - $this->validateItems($attribute, $value, $fail); - } - - public static function field(string|array|ValidationRule|Rule $rules = []): self - { - $instance = new static(); - $instance->fieldRules = self::getRulesAsArray($rules); - - if (! in_array('array', $instance->getFieldRules())) { - $instance->fieldRules[] = 'array'; - } - - return $instance; - } - - protected function validateItems(string $attribute, array $items, Closure $fail): void - { - $cleanAttribute = Str::afterLast($attribute, '.'); - foreach ($items as $file) { - $validator = Validator::make([$cleanAttribute => $file], [ - $cleanAttribute => $this->getFileRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages() ?? [] as $attr => $message) { - foreach ($message as $messageText) { - $fail($messageText)->translate(); - } - } - } - } - } - - protected function validateArrayData(string $attribute, Closure $fail, null|array $data = null, null|array $rules = null): void - { - $data = $data ?? $this->data; - $rules = $rules ?? $this->getFieldRules(); - $validationRuleAttribute = $this->getValidationAttributeString($attribute); - $validator = Validator::make($data, [ - $validationRuleAttribute => $rules, - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } - } - } - - protected static function ensureValidValue($value) - { - if (! is_array($value)) { - try { - $value = json_decode($value, true); - } catch (\Exception $e) { - return false; - } - } - - return $value; - } - - private function getValidationAttributeString($attribute) - { - return Str::substrCount($attribute, '.') > 1 ? - Str::before($attribute, '.').'.*.'.Str::afterLast($attribute, '.') : - $attribute; - } -} diff --git a/src/app/Library/Validation/Rules/ValidUpload.php b/src/app/Library/Validation/Rules/ValidUpload.php index b997e322e6..cd2e7e1b78 100644 --- a/src/app/Library/Validation/Rules/ValidUpload.php +++ b/src/app/Library/Validation/Rules/ValidUpload.php @@ -3,45 +3,56 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade; -use Backpack\CRUD\app\Library\Validation\Rules\Support\HasFiles; -use Closure; use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Support\Facades\Validator; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; class ValidUpload extends BackpackCustomRule { - use HasFiles; - /** - * Run the validation rule. - * - * @param string $attribute - * @param mixed $value - * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail - * @return void + * Run the validation rule and return the array of errors. */ - public function validate(string $attribute, mixed $value, Closure $fail): void + public function validateRules(string $attribute, mixed $value): array { $entry = CrudPanelFacade::getCurrentEntry(); - if (! array_key_exists($attribute, $this->data) && $entry) { - return; + // if the attribute is not set in the request, and an entry exists, + // we will check if there is a previous value, as this field might not have changed. + if (! Arr::has($this->data, $attribute) && $entry) { + if (str_contains($attribute, '.') && get_class($entry) === get_class(CrudPanelFacade::getModel())) { + $previousValue = Arr::get($this->data, '_order_'.Str::before($attribute, '.')); + $previousValue = Arr::get($previousValue, Str::after($attribute, '.')); + } else { + $previousValue = Arr::get($entry, $attribute); + } + + if ($previousValue && empty($value)) { + return []; + } + + Arr::set($this->data, $attribute, $previousValue ?? $value); } - $this->validateFieldRules($attribute, $value, $fail); + // if the value is an uploaded file, or the attribute is not + // set in the request, we force fill the data with the value + if ($value instanceof UploadedFile || ! Arr::has($this->data, $attribute)) { + Arr::set($this->data, $attribute, $value); + } + + // if there are no entry, and the new value it's not a file ... well we don't want it at all. + if (! $entry && ! $value instanceof UploadedFile) { + Arr::set($this->data, $attribute, null); + } + + $fieldErrors = $this->validateFieldRules($attribute); if (! empty($value) && ! empty($this->getFileRules())) { - $validator = Validator::make([$attribute => $value], [ - $attribute => $this->getFileRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } - } + $fileErrors = $this->validateFileRules($attribute, $value); } + + return array_merge($fieldErrors, $fileErrors ?? []); } public static function field(string|array|ValidationRule|Rule $rules = []): self diff --git a/src/app/Library/Validation/Rules/ValidUploadMultiple.php b/src/app/Library/Validation/Rules/ValidUploadMultiple.php index 02bea084c4..f0432c8751 100644 --- a/src/app/Library/Validation/Rules/ValidUploadMultiple.php +++ b/src/app/Library/Validation/Rules/ValidUploadMultiple.php @@ -3,61 +3,57 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade; -use Closure; +use Backpack\CRUD\app\Library\Validation\Rules\Support\ValidateArrayContract; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; -class ValidUploadMultiple extends ValidFileArray +class ValidUploadMultiple extends BackpackCustomRule implements ValidateArrayContract { - /** - * Run the validation rule. - * - * @param string $attribute - * @param mixed $value - * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail - * @return void - */ - public function validate(string $attribute, mixed $value, Closure $fail): void + public function validateRules(string $attribute, mixed $value): array { - if (! $value = self::ensureValidValue($value)) { - $fail('Unable to determine the value type.'); - - return; - } - $entry = CrudPanelFacade::getCurrentEntry() !== false ? CrudPanelFacade::getCurrentEntry() : null; - + $data = $this->data; // `upload_multiple` sends [[0 => null]] when user doesn't upload anything // assume that nothing changed on field so nothing is sent on the request. if (count($value) === 1 && empty($value[0])) { - if ($entry) { - unset($this->data[$attribute]); - } else { - $this->data[$attribute] = []; - } + Arr::set($data, $attribute, []); $value = []; } - $previousValues = $entry?->{$attribute} ?? []; + $previousValues = str_contains($attribute, '.') ? + (Arr::get($entry?->{Str::before($attribute, '.')} ?? [], Str::after($attribute, '.')) ?? []) : + ($entry?->{$attribute} ?? []); + if (is_string($previousValues)) { $previousValues = json_decode($previousValues, true) ?? []; } - $value = array_merge($previousValues, $value); + Arr::set($data, $attribute, array_merge($previousValues, $value)); if ($entry) { $filesDeleted = CrudPanelFacade::getRequest()->input('clear_'.$attribute) ?? []; + Arr::set($data, $attribute, array_diff(Arr::get($data, $attribute), $filesDeleted)); - $data = $this->data; - $data[$attribute] = array_diff($value, $filesDeleted); + return $this->validateFieldAndFile($attribute, $data); + } - $this->validateArrayData($attribute, $fail, $data); + // if there is no entry, the values we are going to validate need to be files + // the request was tampered so we will set the attribute to null + if (! $entry && ! empty(Arr::get($data, $attribute)) && ! $this->allFiles(Arr::get($data, $attribute))) { + Arr::set($data, $attribute, null); + } - $this->validateItems($attribute, $value, $fail); + return $this->validateFieldAndFile($attribute, $data); + } - return; + private function allFiles(array $values): bool + { + foreach ($values as $value) { + if (! $value instanceof \Illuminate\Http\UploadedFile) { + return false; + } } - $this->validateArrayData($attribute, $fail); - - $this->validateItems($attribute, $value, $fail); + return true; } } diff --git a/src/app/View/Components/FiltersNavbar.php b/src/app/View/Components/FiltersNavbar.php new file mode 100644 index 0000000000..ebd1d9e689 --- /dev/null +++ b/src/app/View/Components/FiltersNavbar.php @@ -0,0 +1,31 @@ +crud = $crud; + $this->id = $id ?? 'filters-navbar-'.uniqid(); + } + + /** + * Get the view / contents that represent the component. + */ + public function render(): View|Closure|string + { + return backpack_view('components.filters-navbar'); + } +} diff --git a/src/macros.php b/src/macros.php index 1f455d028f..27f03f5442 100644 --- a/src/macros.php +++ b/src/macros.php @@ -36,8 +36,10 @@ } if (! CrudColumn::hasMacro('withFiles')) { CrudColumn::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) { + /** @var CrudColumn $this */ $uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : []; - /** @var CrudField|CrudColumn $this */ + $this->setAttributeValue('withFiles', $uploadDefinition); + $this->save(); RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents); return $this; @@ -46,8 +48,10 @@ if (! CrudField::hasMacro('withFiles')) { CrudField::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) { + /** @var CrudField $this */ $uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : []; - /** @var CrudField|CrudColumn $this */ + $this->setAttributeValue('withFiles', $uploadDefinition); + $this->save(); RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents); return $this; @@ -78,7 +82,7 @@ // if the route doesn't exist, we'll throw an exception if (! $routeInstance = Route::getRoutes()->getByName($route)) { - throw new \Exception("Route [{$route}] not found while building the link for column [{$this->attributes['name']}]."); + throw new Exception("Route [{$route}] not found while building the link for column [{$this->attributes['name']}]."); } // calculate the parameters we'll be using for the route() call @@ -92,7 +96,7 @@ $autoInferredParameter = array_diff($expectedParameters, array_keys($parameters)); if (count($autoInferredParameter) > 1) { - throw new \Exception("Route [{$route}] expects parameters [".implode(', ', $expectedParameters)."]. Insufficient parameters provided in column: [{$this->attributes['name']}]."); + throw new Exception("Route [{$route}] expects parameters [".implode(', ', $expectedParameters)."]. Insufficient parameters provided in column: [{$this->attributes['name']}]."); } $autoInferredParameter = current($autoInferredParameter) ? [current($autoInferredParameter) => function ($entry, $related_key, $column, $crud) { $entity = $crud->isAttributeInRelationString($column) ? Str::before($column['entity'], '.') : $column['entity']; @@ -110,7 +114,7 @@ try { return route($route, $parameters); - } catch (\Exception $e) { + } catch (Exception $e) { return false; } }; @@ -128,11 +132,11 @@ $route = "$entity.show"; if (! $entity) { - throw new \Exception("Entity not found while building the link for column [{$name}]."); + throw new Exception("Entity not found while building the link for column [{$name}]."); } if (! Route::getRoutes()->getByName($route)) { - throw new \Exception("Route '{$route}' not found while building the link for column [{$name}]."); + throw new Exception("Route '{$route}' not found while building the link for column [{$name}]."); } // set up the link to the show page @@ -187,6 +191,6 @@ $groupNamespace = ''; } - \Backpack\CRUD\app\Library\CrudPanel\CrudRouter::setupControllerRoutes($name, $routeName, $controller, $groupNamespace); + Backpack\CRUD\app\Library\CrudPanel\CrudRouter::setupControllerRoutes($name, $routeName, $controller, $groupNamespace); }); } diff --git a/src/resources/views/crud/fields/summernote.blade.php b/src/resources/views/crud/fields/summernote.blade.php index ad04ba173a..4a8e08d4b8 100644 --- a/src/resources/views/crud/fields/summernote.blade.php +++ b/src/resources/views/crud/fields/summernote.blade.php @@ -13,6 +13,9 @@ name="{{ $field['name'] }}" data-init-function="bpFieldInitSummernoteElement" data-options="{{ json_encode($field['options']) }}" + data-upload-enabled="{{ isset($field['withFiles']) || isset($field['withMedia']) || isset($field['imageUploadEndpoint']) ? 'true' : 'false'}}" + data-upload-endpoint="{{ isset($field['imageUploadEndpoint']) ? $field['imageUploadEndpoint'] : 'false'}}" + data-upload-operation="{{ $crud->get('ajax-upload.formOperation') }}" bp-field-main-input @include('crud::fields.inc.attributes', ['default_class' => 'form-control summernote']) >{{ old_empty_or_null($field['name'], '') ?? $field['value'] ?? $field['default'] ?? '' }} @@ -31,8 +34,8 @@ {{-- FIELD CSS - will be loaded in the after_styles section --}} @push('crud_fields_styles') {{-- include summernote css --}} - @basset('https://unpkg.com/summernote@0.8.20/dist/summernote-lite.min.css') - @basset('https://unpkg.com/summernote@0.8.20/dist/font/summernote.woff2', false) + @basset('https://unpkg.com/summernote@0.9.1/dist/summernote-lite.min.css') + @basset('https://unpkg.com/summernote@0.9.1/dist/font/summernote.woff2', false) @bassetBlock('backpack/crud/fields/summernote-field.css')