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/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..ecb5c9a084 100644 --- a/src/app/Library/Uploaders/MultipleFiles.php +++ b/src/app/Library/Uploaders/MultipleFiles.php @@ -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); } } diff --git a/src/app/Library/Uploaders/SingleBase64Image.php b/src/app/Library/Uploaders/SingleBase64Image.php index 97c4e27488..6085304c21 100644 --- a/src/app/Library/Uploaders/SingleBase64Image.php +++ b/src/app/Library/Uploaders/SingleBase64Image.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +/** @codeCoverageIgnore */ class SingleBase64Image extends Uploader { public function uploadFiles(Model $entry, $value = null) @@ -51,7 +52,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); @@ -65,7 +66,7 @@ protected 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'); } diff --git a/src/app/Library/Uploaders/SingleFile.php b/src/app/Library/Uploaders/SingleFile.php index 7a3f1318d9..4a064fb5b8 100644 --- a/src/app/Library/Uploaders/SingleFile.php +++ b/src/app/Library/Uploaders/SingleFile.php @@ -38,6 +38,7 @@ public function uploadFiles(Model $entry, $value = null) return $previousFile; } + /** @codeCoverageIgnore */ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry = null) { $orderedFiles = $this->getFileOrderFromRequest(); @@ -53,9 +54,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,7 +70,7 @@ 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); } diff --git a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php index 185350c053..20df54dbc0 100644 --- a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php +++ b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php @@ -30,6 +30,8 @@ public function repeats(string $repeatableContainerName): self; public function relationship(bool $isRelation): self; + public function fake(bool|string $isFake): self; + /** * Getters. */ @@ -60,4 +62,10 @@ public function canHandleMultipleFiles(): bool; public function isRelationship(): bool; public function getPreviousFiles(Model $entry): mixed; + + public function getValueWithoutPath(?string $value = null): ?string; + + public function isFake(): bool; + + public function getFakeAttribute(): bool|string; } 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..30465ec4ee 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); + + 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; } @@ -117,17 +152,12 @@ 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 + protected function processRepeatableUploads(Model $entry, Collection $values): array { foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $uploader) { $uploadedValues = $uploader->uploadRepeatableFiles($values->pluck($uploader->getAttributeName())->toArray(), $this->getPreviousRepeatableValues($entry, $uploader)); @@ -139,7 +169,7 @@ protected function processRepeatableUploads(Model $entry, Collection $values): C }); } - return $values; + return $values->toArray(); } private function retrieveRepeatableFiles(Model $entry): Model @@ -150,6 +180,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 +199,7 @@ private function retrieveRepeatableFiles(Model $entry): Model } return $item; - }, $values); + }, $values ?? []); $entry->{$this->getRepeatableContainerName()} = $values; @@ -211,7 +252,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 +321,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 +334,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; + } - return; - } - } + $pivotAttributes = $entry->getAttributes(); + $connectedPivot = $entry->pivotParent->{$this->getRepeatableContainerName()}->where(function ($item) use ($pivotAttributes) { + $itemPivotAttributes = $item->pivot->only(array_keys($pivotAttributes)); - if (is_array($files)) { - foreach ($files as $value) { - $value = Str::start($value, $this->getPath()); - Storage::disk($this->getDisk())->delete($value); - } + return $itemPivotAttributes === $pivotAttributes; + })->first(); + + 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..e1edca0c96 100644 --- a/src/app/Library/Uploaders/Uploader.php +++ b/src/app/Library/Uploaders/Uploader.php @@ -21,7 +21,7 @@ abstract class Uploader implements UploaderInterface private string $path = ''; - private bool $handleMultipleFiles = false; + public bool $handleMultipleFiles = false; private bool $deleteWhenEntryIsDeleted = true; @@ -102,16 +102,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); } } @@ -182,13 +180,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 isFake(): bool + { + return $this->attachedToFakeField !== false; + } + + public function getFakeAttribute(): bool|string + { + return $this->attachedToFakeField; + } + + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + { + return $entry->exists && ($entryValue === null || $entryValue === [null]); + } + /******************************* * Setters - fluently configure the uploader *******************************/ @@ -206,6 +223,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 +241,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 +286,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 +303,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 +313,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/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/upload.blade.php b/src/resources/views/crud/fields/upload.blade.php index 1be87a81ce..847844290e 100644 --- a/src/resources/views/crud/fields/upload.blade.php +++ b/src/resources/views/crud/fields/upload.blade.php @@ -2,6 +2,15 @@ $field['wrapper'] = $field['wrapper'] ?? $field['wrapperAttributes'] ?? []; $field['wrapper']['data-init-function'] = $field['wrapper']['data-init-function'] ?? 'bpFieldInitUploadElement'; $field['wrapper']['data-field-name'] = $field['wrapper']['data-field-name'] ?? $field['name']; + + // if it has a base name, it's a subfield in a repeatable. we are going to re-set the value from the old input + if(isset($field['parentFieldName'])) { + if(!empty(old())) { + $field['value'] = Arr::get(old(), square_brackets_to_dots($field['name'])) ?? + Arr::get(old(), '_order_'.square_brackets_to_dots($field['name'])) ?? + Arr::get(old(), '_clear_'.square_brackets_to_dots($field['name'])); + } + } @endphp {{-- text input --}} @@ -14,7 +23,7 @@
@if (isset($field['disk'])) @if (isset($field['temporary'])) - + @else @endif diff --git a/src/resources/views/crud/fields/upload_multiple.blade.php b/src/resources/views/crud/fields/upload_multiple.blade.php index 9225dbb9f3..b318b5b662 100644 --- a/src/resources/views/crud/fields/upload_multiple.blade.php +++ b/src/resources/views/crud/fields/upload_multiple.blade.php @@ -2,6 +2,17 @@ $field['wrapper'] = $field['wrapper'] ?? $field['wrapperAttributes'] ?? []; $field['wrapper']['data-init-function'] = $field['wrapper']['data-init-function'] ?? 'bpFieldInitUploadMultipleElement'; $field['wrapper']['data-field-name'] = $field['wrapper']['data-field-name'] ?? $field['name']; + + if(isset($field['parentFieldName'])) { + if(!empty(old())) { + $field['value'] = array_merge( + explode(',',Arr::get(old(), '_order_'.square_brackets_to_dots($field['name'])) ?? ''), + Arr::get(old(), 'clear_'.square_brackets_to_dots($field['name'])) ?? [], + ); + $field['value'] = is_array($field['value']) ? array_filter($field['value'] ?? []) : []; + $field['value'] = $field['value'] === [null] || $field['value'] === [""] ? null : $field['value']; + } + } @endphp {{-- upload multiple input --}} @@ -23,7 +34,7 @@ @foreach($values as $key => $file_path)
@if (isset($field['temporary'])) - {{ $file_path }} + {{ $file_path }} @else {{ $file_path }} @endif @@ -244,7 +255,7 @@ function bpFieldInitUploadMultipleElement(element) { } // remove the hidden input, so that the setXAttribute method is no longer triggered - $(this).next("input[type=hidden]:not([name='clear_"+fieldName+"[]'])").remove(); + $(this).next("input[type=hidden]:not([name='clear_"+fieldName+"[]']):not([name='_order_"+fieldName+"'])").remove(); }); element.find('input').on('CrudField:disable', function(e) { diff --git a/tests/BaseTestClass.php b/tests/BaseTestClass.php index f3ef9b9b32..3d535fe7c8 100644 --- a/tests/BaseTestClass.php +++ b/tests/BaseTestClass.php @@ -37,6 +37,8 @@ function () { protected function getPackageProviders($app) { return [ + TestsServiceProvider::class, + AlertsServiceProvider::class, BassetServiceProvider::class, BackpackServiceProvider::class, AlertsServiceProvider::class, diff --git a/tests/Feature/FakeUploadersTest.php b/tests/Feature/FakeUploadersTest.php new file mode 100644 index 0000000000..a3c9edf485 --- /dev/null +++ b/tests/Feature/FakeUploadersTest.php @@ -0,0 +1,233 @@ +crud(config('backpack.base.route_prefix').'/fake-uploader', FakeUploaderCrudController::class); + } + + protected function setUp(): void + { + parent::setUp(); + Storage::fake('uploaders'); + $this->actingAs(User::find(1)); + $this->testBaseUrl = config('backpack.base.route_prefix').'/fake-uploader'; + } + + public function test_it_can_access_the_uploaders_create_page() + { + $response = $this->get($this->testBaseUrl.'/create'); + $response->assertStatus(200); + } + + public function test_it_can_store_uploaded_files() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertDatabaseHas('uploaders', [ + 'id' => 1, + 'extras' => json_encode(['upload' => 'avatar1.jpg', 'upload_multiple' => ['avatar2.jpg', 'avatar3.jpg']]), + ]); + } + + public function test_it_display_the_edit_page_without_files() + { + self::initUploader(); + + $response = $this->get($this->testBaseUrl.'/1/edit'); + $response->assertStatus(200); + } + + public function test_it_display_the_upload_page_with_files() + { + self::initUploaderWithFiles(); + $response = $this->get($this->testBaseUrl.'/1/edit'); + + $response->assertStatus(200); + + $response->assertSee('avatar1.jpg'); + $response->assertSee('avatar2.jpg'); + $response->assertSee('avatar3.jpg'); + } + + public function test_it_can_update_uploaded_files() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + '_method' => 'PUT', + 'upload' => $this->getUploadedFile('avatar4.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar5.jpg', 'avatar6.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'extras' => json_encode(['upload' => 'avatar4.jpg', 'upload_multiple' => ['avatar5.jpg', 'avatar6.jpg']]), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar6.jpg')); + } + + public function test_single_upload_deletes_files_when_updated_without_values() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => null, + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'extras' => json_encode(['upload' => null, 'upload_multiple' => ['avatar2.jpg', 'avatar3.jpg']]), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(2, count($files)); + + $this->assertFalse(Storage::disk('uploaders')->exists('avatar1.jpg')); + } + + public function test_it_can_delete_uploaded_files() + { + self::initUploaderWithFiles(); + + $response = $this->delete($this->testBaseUrl.'/1'); + + $response->assertStatus(200); + + $this->assertDatabaseCount('uploaders', 0); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(0, count($files)); + } + + public function test_it_keeps_previous_values_unchaged_when_not_deleted() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => [null], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'extras' => json_encode(['upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], 'upload' => 'avatar1.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar2.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + } + + public function test_upload_multiple_can_delete_uploaded_files_and_add_at_the_same_time() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => $this->getUploadedFiles(['avatar4.jpg', 'avatar5.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'extras' => json_encode(['upload_multiple' => ['avatar3.jpg', 'avatar4.jpg', 'avatar5.jpg'], 'upload' => 'avatar1.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(4, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + } + + protected static function initUploaderWithFiles() + { + UploadedFile::fake()->image('avatar1.jpg')->storeAs('', 'avatar1.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar2.jpg')->storeAs('', 'avatar2.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar3.jpg')->storeAs('', 'avatar3.jpg', ['disk' => 'uploaders']); + + FakeUploader::create([ + 'extras' => ['upload' => 'avatar1.jpg', 'upload_multiple' => ['avatar2.jpg', 'avatar3.jpg']], + ]); + } + + protected static function initUploader() + { + FakeUploader::create([ + 'extras' => ['upload' => null, 'upload_multiple' => null], + ]); + } +} diff --git a/tests/Feature/UploadersConfigurationTest.php b/tests/Feature/UploadersConfigurationTest.php new file mode 100644 index 0000000000..33a1924227 --- /dev/null +++ b/tests/Feature/UploadersConfigurationTest.php @@ -0,0 +1,125 @@ +crud(config('backpack.base.route_prefix').'/uploader-configuration', UploaderConfigurationCrudController::class); + } + + protected function setUp(): void + { + parent::setUp(); + $this->testBaseUrl = config('backpack.base.route_prefix').'/uploader-configuration'; + Storage::fake('uploaders'); + $this->actingAs(User::find(1)); + } + + public function test_it_can_access_the_uploaders_create_page() + { + $response = $this->get($this->testBaseUrl.'/create'); + $response->assertStatus(200); + } + + public function test_it_can_store_uploaded_files_using_our_file_name_generator() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + foreach ($files as $file) { + $this->assertMatchesRegularExpression('/avatar\d{1}-[a-zA-Z0-9]{4}\.jpg/', $file); + } + + // get the entry from database and also make sure the file names are stored correctly + $entry = Uploader::first(); + $this->assertNotNull($entry); + $this->assertMatchesRegularExpression('/avatar\d{1}-[a-zA-Z0-9]{4}\.jpg/', $entry->upload); + $this->assertMatchesRegularExpression('/avatar\d{1}-[a-zA-Z0-9]{4}\.jpg/', $entry->upload_multiple[0]); + $this->assertMatchesRegularExpression('/avatar\d{1}-[a-zA-Z0-9]{4}\.jpg/', $entry->upload_multiple[1]); + } + + public function test_it_validates_the_file_namer_invalid_string() + { + $this->expectException(\Exception::class); + + $response = $this->get($this->testBaseUrl.'/invalid-file-namer'); + + $response->assertStatus(500); + + throw $response->exception; + } + + public function test_it_validates_the_file_namer_invalid_class() + { + $this->expectException(\Exception::class); + + $response = $this->get($this->testBaseUrl.'/invalid-file-namer-class'); + + $response->assertStatus(500); + + throw $response->exception; + } + + public function test_it_can_use_a_custom_uploader() + { + $response = $this->post($this->testBaseUrl.'/custom-uploader', [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(1, count($files)); + } + + public function test_it_validates_the_custom_uploader_class() + { + $this->expectException(\Exception::class); + + $response = $this->post($this->testBaseUrl.'/custom-invalid-uploader', [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + ]); + + $response->assertStatus(500); + + throw $response->exception; + } +} diff --git a/tests/Feature/UploadersTest.php b/tests/Feature/UploadersTest.php new file mode 100644 index 0000000000..21ff6c5ed7 --- /dev/null +++ b/tests/Feature/UploadersTest.php @@ -0,0 +1,244 @@ +crud(config('backpack.base.route_prefix').'/uploader', UploaderCrudController::class); + } + + protected function setUp(): void + { + parent::setUp(); + $this->testBaseUrl = config('backpack.base.route_prefix').'/uploader'; + Storage::fake('uploaders'); + $this->actingAs(User::find(1)); + } + + public function test_it_can_access_the_uploaders_create_page() + { + $response = $this->get($this->testBaseUrl.'/create'); + $response->assertStatus(200); + } + + public function test_it_can_store_uploaded_files() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar2.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + } + + public function test_it_display_the_edit_page_without_files() + { + self::initUploader(); + + $response = $this->get($this->testBaseUrl.'/1/edit'); + $response->assertStatus(200); + } + + public function test_it_display_the_upload_page_with_files() + { + self::initUploaderWithFiles(); + $response = $this->get($this->testBaseUrl.'/1/edit'); + + $response->assertStatus(200); + + $response->assertSee('avatar1.jpg'); + $response->assertSee('avatar2.jpg'); + $response->assertSee('avatar3.jpg'); + } + + public function test_it_can_update_uploaded_files() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => $this->getUploadedFile('avatar4.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar5.jpg', 'avatar6.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar4.jpg', + 'upload_multiple' => json_encode(['avatar5.jpg', 'avatar6.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar6.jpg')); + } + + public function test_single_upload_deletes_files_when_updated_without_values() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => null, + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => null, + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(2, count($files)); + + $this->assertFalse(Storage::disk('uploaders')->exists('avatar1.jpg')); + } + + public function test_it_can_delete_uploaded_files() + { + self::initUploaderWithFiles(); + + $response = $this->delete($this->testBaseUrl.'/1'); + + $response->assertStatus(200); + + $this->assertDatabaseCount('uploaders', 0); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(0, count($files)); + } + + public function test_it_keeps_previous_values_unchaged_when_not_deleted() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar2.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + } + + public function test_upload_multiple_can_delete_uploaded_files_and_add_at_the_same_time() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => $this->getUploadedFiles(['avatar4.jpg', 'avatar5.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar3.jpg', 'avatar4.jpg', 'avatar5.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(4, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + } + + protected static function initUploaderWithFiles() + { + UploadedFile::fake()->image('avatar1.jpg')->storeAs('', 'avatar1.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar2.jpg')->storeAs('', 'avatar2.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar3.jpg')->storeAs('', 'avatar3.jpg', ['disk' => 'uploaders']); + + Uploader::create([ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + } + + protected static function initUploader() + { + Uploader::create([ + 'upload' => null, + 'upload_multiple' => null, + ]); + } +} diff --git a/tests/Feature/UploadersValidationTest.php b/tests/Feature/UploadersValidationTest.php new file mode 100644 index 0000000000..fd72f95438 --- /dev/null +++ b/tests/Feature/UploadersValidationTest.php @@ -0,0 +1,410 @@ +crud(config('backpack.base.route_prefix').'/uploader-validation', UploaderValidationCrudController::class); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->testBaseUrl = config('backpack.base.route_prefix').'/uploader-validation'; + + Storage::fake('uploaders'); + $this->actingAs(User::find(1)); + } + + public function test_it_can_access_the_uploaders_create_page() + { + $response = $this->get($this->testBaseUrl.'/create'); + $response->assertStatus(200); + } + + public function test_it_can_store_uploaded_files() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar2.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + } + + public function test_it_display_the_edit_page_without_files() + { + self::initUploader(); + + $response = $this->get($this->testBaseUrl.'/1/edit'); + $response->assertStatus(200); + } + + public function test_it_display_the_upload_page_with_files() + { + self::initUploaderWithImages(); + + $response = $this->get($this->testBaseUrl.'/1/edit'); + + $response->assertStatus(200); + + $response->assertSee('avatar1.jpg'); + $response->assertSee('avatar2.jpg'); + $response->assertSee('avatar3.jpg'); + } + + public function test_it_can_update_uploaded_files() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => $this->getUploadedFile('avatar4.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar5.jpg', 'avatar6.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar4.jpg', + 'upload_multiple' => json_encode(['avatar5.jpg', 'avatar6.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar6.jpg')); + } + + public function test_it_can_delete_uploaded_files() + { + self::initUploaderWithImages(); + + $response = $this->delete($this->testBaseUrl.'/1'); + + $response->assertStatus(200); + + $this->assertDatabaseCount('uploaders', 0); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(0, count($files)); + } + + public function test_it_keeps_previous_values_unchaged_when_not_deleted() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(3, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar2.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + } + + public function test_upload_multiple_can_delete_uploaded_files_and_add_at_the_same_time() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => $this->getUploadedFiles(['avatar4.jpg', 'avatar5.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertRedirect($this->testBaseUrl); + + $this->assertDatabaseCount('uploaders', 1); + + $this->assertDatabaseHas('uploaders', [ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar3.jpg', 'avatar4.jpg', 'avatar5.jpg']), + ]); + + $files = Storage::disk('uploaders')->allFiles(); + + $this->assertEquals(4, count($files)); + + $this->assertTrue(Storage::disk('uploaders')->exists('avatar1.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar3.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar4.jpg')); + $this->assertTrue(Storage::disk('uploaders')->exists('avatar5.jpg')); + } + + public function test_it_validates_files_on_a_single_upload() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => 'not-a-file', + 'upload_multiple' => $this->getUploadedFiles(['avatar1.jpg', 'avatar2.jpg']), + ]); + + $response->assertStatus(302); + $response->assertSessionHasErrors('upload'); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_files_on_multiple_uploader() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => array_merge($this->getUploadedFiles(['avatar1.jpg']), ['not-a-file']), + ]); + + $response->assertStatus(302); + $response->assertSessionHasErrors('upload_multiple'); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_mime_types_on_single_and_multi_uploads() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.pdf', 'application/pdf'), + 'upload_multiple' => $this->getUploadedFiles(['avatar1.pdf', 'avatar1.pdf'], 'application/pdf'), + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload_multiple'); + $response->assertSessionHasErrors('upload'); + + // assert the error content + $this->assertEquals('The upload multiple field must be a file of type: jpg.', session('errors')->get('upload_multiple')[0]); + $this->assertEquals('The upload field must be a file of type: jpg.', session('errors')->get('upload')[0]); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_file_size_on_single_and_multi_uploads() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1_big.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2_big.jpg', 'avatar3_big.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload_multiple'); + $response->assertSessionHasErrors('upload'); + + // assert the error content + $this->assertEquals('The upload multiple field must not be greater than 100 kilobytes.', session('errors')->get('upload_multiple')[0]); + $this->assertEquals('The upload field must not be greater than 100 kilobytes.', session('errors')->get('upload')[0]); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_min_files_on_multi_uploads() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => $this->getUploadedFile('avatar1.jpg'), + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg']), + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload_multiple'); + + // assert the error content + $this->assertEquals('The upload multiple field must have at least 2 items.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_required_files_on_single_and_multi_uploads() + { + $response = $this->post($this->testBaseUrl, [ + 'upload' => null, + 'upload_multiple' => null, + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload'); + $response->assertSessionHasErrors('upload_multiple'); + + // assert the error content + $this->assertEquals('The upload field is required.', session('errors')->get('upload')[0]); + $this->assertEquals('The upload multiple field is required.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_required_when_not_present_in_request() + { + $response = $this->post($this->testBaseUrl, []); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload'); + $response->assertSessionHasErrors('upload_multiple'); + + $this->assertEquals('The upload field is required.', session('errors')->get('upload')[0]); + $this->assertEquals('The upload multiple field is required.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 0); + } + + public function test_it_validates_required_files_on_single_and_multi_uploads_when_updating() + { + self::initUploader(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => null, + 'upload_multiple' => null, + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload'); + $response->assertSessionHasErrors('upload_multiple'); + + // assert the error content + $this->assertEquals('The upload field is required.', session('errors')->get('upload')[0]); + $this->assertEquals('The upload multiple field is required.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 1); + } + + public function test_it_validates_required_files_on_single_and_multi_uploads_when_updating_with_files() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload' => null, + 'upload_multiple' => null, + 'clear_upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload'); + $response->assertSessionHasErrors('upload_multiple'); + + // assert the error content + $this->assertEquals('The upload field is required.', session('errors')->get('upload')[0]); + $this->assertEquals('The upload multiple field is required.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 1); + } + + public function test_it_validates_min_files_on_multi_uploads_when_updating() + { + self::initUploaderWithFiles(); + + $response = $this->put($this->testBaseUrl.'/1', [ + 'upload_multiple' => $this->getUploadedFiles(['avatar2.jpg']), + 'clear_upload_multiple' => ['avatar2.jpg', 'avatar3.jpg'], + 'id' => 1, + ]); + + $response->assertStatus(302); + + $response->assertSessionHasErrors('upload_multiple'); + + // assert the error content + $this->assertEquals('The upload multiple field must have at least 2 items.', session('errors')->get('upload_multiple')[0]); + + $this->assertDatabaseCount('uploaders', 1); + } + + protected static function initUploaderWithImages() + { + UploadedFile::fake()->image('avatar1.jpg')->storeAs('', 'avatar1.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar2.jpg')->storeAs('', 'avatar2.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->image('avatar3.jpg')->storeAs('', 'avatar3.jpg', ['disk' => 'uploaders']); + + Uploader::create([ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + } + + protected static function initUploaderWithFiles() + { + UploadedFile::fake()->create('avatar1.jpg')->storeAs('', 'avatar1.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->create('avatar2.jpg')->storeAs('', 'avatar2.jpg', ['disk' => 'uploaders']); + UploadedFile::fake()->create('avatar3.jpg')->storeAs('', 'avatar3.jpg', ['disk' => 'uploaders']); + + Uploader::create([ + 'upload' => 'avatar1.jpg', + 'upload_multiple' => json_encode(['avatar2.jpg', 'avatar3.jpg']), + ]); + } + + protected static function initUploader() + { + Uploader::create([ + 'upload' => null, + 'upload_multiple' => null, + ]); + } +} diff --git a/tests/Unit/CrudPanel/CrudPanelColumnsLinkToTest.php b/tests/Unit/CrudPanel/CrudPanelColumnsLinkToTest.php new file mode 100644 index 0000000000..d82aaacd6f --- /dev/null +++ b/tests/Unit/CrudPanel/CrudPanelColumnsLinkToTest.php @@ -0,0 +1,174 @@ +crudPanel->setOperation('list'); + } + + public function testColumnLinkToThrowsExceptionWhenNotAllRequiredParametersAreFilled() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Route [article.show.detail] expects parameters [id, detail]. Insufficient parameters provided in column: [articles].'); + $this->crudPanel->column('articles')->entity('articles')->linkTo('article.show.detail', ['test' => 'testing']); + } + + public function testItThrowsExceptionIfRouteNotFound() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Route [users.route.doesnt.exist] not found while building the link for column [id].'); + + CrudColumn::name('id')->linkTo('users.route.doesnt.exist')->toArray(); + } + + public function testColumnLinkToWithRouteNameOnly() + { + $this->crudPanel->column('articles')->entity('articles')->linkTo('articles.show'); + $columnArray = $this->crudPanel->columns()['articles']; + $reflection = new \ReflectionFunction($columnArray['wrapper']['href']); + $arguments = $reflection->getClosureUsedVariables(); + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('articles.show', $arguments['route']); + $this->assertCount(1, $arguments['parameters']); + $this->assertEquals('http://localhost/admin/articles/1/show', $url); + } + + public function testColumnLinkToWithRouteNameAndAdditionalParameters() + { + $this->crudPanel->column('articles')->entity('articles')->linkTo('articles.show', ['test' => 'testing', 'test2' => 'testing2']); + $columnArray = $this->crudPanel->columns()['articles']; + $reflection = new \ReflectionFunction($columnArray['wrapper']['href']); + $arguments = $reflection->getClosureUsedVariables(); + $this->assertEquals('articles.show', $arguments['route']); + $this->assertCount(3, $arguments['parameters']); + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show?test=testing&test2=testing2', $url); + } + + public function testColumnLinkToWithCustomParameters() + { + $this->crudPanel->column('articles')->entity('articles')->linkTo('article.show.detail', ['detail' => 'testing', 'otherParam' => 'test']); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show/testing?otherParam=test', $url); + } + + public function testColumnLinkToWithCustomClosureParameters() + { + $this->crudPanel->column('articles') + ->entity('articles') + ->linkTo('article.show.detail', ['detail' => fn ($entry, $related_key) => $related_key, 'otherParam' => fn ($entry) => $entry->content]); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show/1?otherParam=Some%20Content', $url); + } + + public function testColumnLinkToDontAutoInferParametersIfAllProvided() + { + $this->crudPanel->column('articles') + ->entity('articles') + ->linkTo('article.show.detail', ['id' => 123, 'detail' => fn ($entry, $related_key) => $related_key, 'otherParam' => fn ($entry) => $entry->content]); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/123/show/1?otherParam=Some%20Content', $url); + } + + public function testColumnLinkToAutoInferAnySingleParameter() + { + $this->crudPanel->column('articles') + ->entity('articles') + ->linkTo('article.show.detail', ['id' => 123, 'otherParam' => fn ($entry) => $entry->content]); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/123/show/1?otherParam=Some%20Content', $url); + } + + public function testColumnLinkToWithClosure() + { + $this->crudPanel->column('articles') + ->entity('articles') + ->linkTo(fn ($entry) => route('articles.show', $entry->content)); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/Some%20Content/show', $url); + } + + public function testColumnArrayDefinitionLinkToRouteAsClosure() + { + $this->crudPanel->setModel(User::class); + $this->crudPanel->column([ + 'name' => 'articles', + 'entity' => 'articles', + 'linkTo' => fn ($entry) => route('articles.show', ['id' => $entry->id, 'test' => 'testing']), + ]); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show?test=testing', $url); + } + + public function testColumnArrayDefinitionLinkToRouteNameOnly() + { + $this->crudPanel->setModel(User::class); + $this->crudPanel->column([ + 'name' => 'articles', + 'entity' => 'articles', + 'linkTo' => 'articles.show', + ]); + $columnArray = $this->crudPanel->columns()['articles']; + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show', $url); + } + + public function testColumnArrayDefinitionLinkToRouteNameAndAdditionalParameters() + { + $this->crudPanel->setModel(User::class); + $this->crudPanel->column([ + 'name' => 'articles', + 'entity' => 'articles', + 'linkTo' => [ + 'route' => 'articles.show', + 'parameters' => [ + 'test' => 'testing', + 'test2' => fn ($entry) => $entry->content, + ], + ], + ]); + $columnArray = $this->crudPanel->columns()['articles']; + $reflection = new \ReflectionFunction($columnArray['wrapper']['href']); + $arguments = $reflection->getClosureUsedVariables(); + $this->assertEquals('articles.show', $arguments['route']); + $this->assertCount(3, $arguments['parameters']); + $this->crudPanel->entry = Article::first(); + $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1); + $this->assertEquals('http://localhost/admin/articles/1/show?test=testing&test2=Some%20Content', $url); + } +} diff --git a/tests/Unit/CrudPanel/CrudPanelFakeColumnsTest.php b/tests/Unit/CrudPanel/CrudPanelFakeColumnsTest.php index 2fcf36d62b..93e9a708de 100644 --- a/tests/Unit/CrudPanel/CrudPanelFakeColumnsTest.php +++ b/tests/Unit/CrudPanel/CrudPanelFakeColumnsTest.php @@ -7,7 +7,7 @@ /** * @covers Backpack\CRUD\app\Library\CrudPanel\Traits\FakeColumns */ -class CrudPanelFakeColumnsTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseDBCrudPanel +class CrudPanelFakeColumnsTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseCrudPanel { private $emptyFakeColumnsArray = ['extras']; diff --git a/tests/Unit/CrudPanel/CrudPanelFieldsTest.php b/tests/Unit/CrudPanel/CrudPanelFieldsTest.php index be6ac8af6e..43107a785e 100644 --- a/tests/Unit/CrudPanel/CrudPanelFieldsTest.php +++ b/tests/Unit/CrudPanel/CrudPanelFieldsTest.php @@ -790,6 +790,7 @@ public function testItCanAddAFluentField() 'type' => 'text', 'entity' => false, 'label' => 'Sub 1', + 'baseFieldName' => 'sub_1', ], ], diff --git a/tests/Unit/CrudPanel/CrudPanelTabsTest.php b/tests/Unit/CrudPanel/CrudPanelTabsTest.php index 98de3434ac..cc3206517b 100644 --- a/tests/Unit/CrudPanel/CrudPanelTabsTest.php +++ b/tests/Unit/CrudPanel/CrudPanelTabsTest.php @@ -11,6 +11,7 @@ class CrudPanelTabsTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseCrudPanel { private $horizontalTabsType = 'horizontal'; + private $verticalTabsType = 'vertical'; private $threeTextFieldsArray = [ diff --git a/tests/Unit/CrudPanel/CrudPanelValidationTest.php b/tests/Unit/CrudPanel/CrudPanelValidationTest.php index 89503025e2..c80bb0a2e1 100644 --- a/tests/Unit/CrudPanel/CrudPanelValidationTest.php +++ b/tests/Unit/CrudPanel/CrudPanelValidationTest.php @@ -14,7 +14,6 @@ * @covers Backpack\CRUD\app\Library\Validation\Rules\BackpackCustomRule * @covers Backpack\CRUD\app\Library\Validation\Rules\ValidUpload * @covers Backpack\CRUD\app\Library\Validation\Rules\ValidUploadMultiple - * @covers Backpack\CRUD\app\Library\Validation\Rules\ValidFileArray * @covers Backpack\CRUD\app\Library\Validation\Rules\Support\HasFiles */ class CrudPanelValidationTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseCrudPanel diff --git a/tests/Unit/Uploaders/UploadersInternalsTest.php b/tests/Unit/Uploaders/UploadersInternalsTest.php new file mode 100644 index 0000000000..ee474389c8 --- /dev/null +++ b/tests/Unit/Uploaders/UploadersInternalsTest.php @@ -0,0 +1,140 @@ +uploaderRepository = $this->app->make('UploadersRepository'); + } + + public function test_it_registers_default_uploaders() + { + $this->assertTrue($this->uploaderRepository->hasUploadFor('image', 'withFiles')); + $this->assertTrue($this->uploaderRepository->hasUploadFor('upload', 'withFiles')); + $this->assertTrue($this->uploaderRepository->hasUploadFor('upload_multiple', 'withFiles')); + + $this->assertFalse($this->uploaderRepository->hasUploadFor('dropzone', 'withFiles')); + } + + public function test_it_registers_default_uploaders_classes() + { + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('image', 'withFiles'), UploaderInterface::class, true)); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('upload', 'withFiles'), UploaderInterface::class, true)); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('upload_multiple', 'withFiles'), UploaderInterface::class, true)); + } + + public function test_it_throws_exception_if_uploader_or_group_is_not_registered() + { + $this->expectException(\Exception::class); + + $this->uploaderRepository->getUploadFor('dropzone', 'withFiles'); + } + + public function test_it_can_add_more_uploaders() + { + $this->uploaderRepository->addUploaderClasses([ + 'dropzone' => SingleFile::class, + ], 'withFiles'); + + $this->assertTrue($this->uploaderRepository->hasUploadFor('dropzone', 'withFiles')); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('dropzone', 'withFiles'), UploaderInterface::class, true)); + } + + public function test_it_validates_uploaders_when_adding() + { + $this->expectException(\Exception::class); + + $this->uploaderRepository->addUploaderClasses([ + 'dropzone' => 'InvalidClass', + ], 'withFiles'); + } + + public function test_it_can_replace_defined_uploaders() + { + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('image', 'withFiles'), SingleBase64Image::class, true)); + + $this->uploaderRepository->addUploaderClasses([ + 'image' => SingleFile::class, + 'dropzone' => SingleFile::class, + ], 'withFiles'); + + $this->assertTrue($this->uploaderRepository->hasUploadFor('dropzone', 'withFiles')); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('dropzone', 'withFiles'), SingleFile::class, true)); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('image', 'withFiles'), SingleFile::class, true)); + } + + public function test_it_can_register_uploaders_in_a_new_group() + { + $this->assertFalse($this->uploaderRepository->hasUploadFor('image', 'newGroup')); + + $this->uploaderRepository->addUploaderClasses([ + 'image' => SingleFile::class, + ], 'newGroup'); + + $this->assertTrue($this->uploaderRepository->hasUploadFor('image', 'newGroup')); + $this->assertTrue(is_a($this->uploaderRepository->getUploadFor('image', 'newGroup'), SingleFile::class, true)); + } + + public function test_it_can_register_repeatable_uploaders() + { + CRUD::field('gallery')->subfields([ + [ + 'name' => 'image', + 'type' => 'image', + 'withFiles' => true, + ], + ]); + + $this->assertTrue($this->uploaderRepository->hasRepeatableUploadersFor('gallery')); + } + + public function test_it_throws_exceptio_if_uploader_doesnt_exist() + { + $this->expectException(\Exception::class); + CRUD::field('upload')->type('custom_type')->withFiles(); + } + + public function test_it_validates_a_custom_uploader() + { + $this->expectException(\Exception::class); + CRUD::field('upload')->type('upload')->withFiles(['uploader' => 'InvalidClass']); + } + + public function test_it_sets_the_prefix_on_field() + { + CRUD::field('upload')->type('upload')->withFiles(['path' => 'test']); + + $this->assertEquals('test/', CRUD::getFields()['upload']['prefix']); + } + + public function test_it_sets_the_disk_on_field() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'test']); + + $this->assertEquals('test', CRUD::getFields()['upload']['disk']); + } + + public function test_it_can_set_temporary_options() + { + CRUD::field('upload')->type('upload')->withFiles(['temporaryUrl' => true]); + + $this->assertTrue(CRUD::getFields()['upload']['temporary']); + $this->assertEquals(1, CRUD::getFields()['upload']['expiration']); + } + + public function test_it_can_get_the_uploaders_registered_macros() + { + $this->assertContains('withFiles', $this->uploaderRepository->getUploadersGroupsNames()); + } +} diff --git a/tests/config/CrudPanel/BaseDBCrudPanel.php b/tests/config/CrudPanel/BaseDBCrudPanel.php index b4cff1e44c..56a6dd1bd8 100644 --- a/tests/config/CrudPanel/BaseDBCrudPanel.php +++ b/tests/config/CrudPanel/BaseDBCrudPanel.php @@ -1,6 +1,6 @@ seed('Backpack\CRUD\Tests\config\database\seeds\MorphableSeeders'); } + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('backpack.base.route_prefix', 'admin'); + + $app->bind('App\Http\Middleware\CheckIfAdmin', function () { + return new class + { + public function handle($request, $next) + { + return $next($request); + } + }; + }); + } + /** * Assert that the attributes of a model entry are equal to the expected array of attributes. * diff --git a/tests/config/Http/Controllers/FakeUploaderCrudController.php b/tests/config/Http/Controllers/FakeUploaderCrudController.php new file mode 100644 index 0000000000..f2e9214884 --- /dev/null +++ b/tests/config/Http/Controllers/FakeUploaderCrudController.php @@ -0,0 +1,43 @@ +type('upload') + ->fake(true) + ->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + CRUD::field('upload_multiple') + ->type('upload_multiple') + ->fake(true) + ->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + } + + protected function setupUpdateOperation() + { + $this->setupCreateOperation(); + } + + public function setupDeleteOperation() + { + $this->setupCreateOperation(); + } +} diff --git a/tests/config/Http/Controllers/UploaderConfigurationCrudController.php b/tests/config/Http/Controllers/UploaderConfigurationCrudController.php new file mode 100644 index 0000000000..51b5e542f5 --- /dev/null +++ b/tests/config/Http/Controllers/UploaderConfigurationCrudController.php @@ -0,0 +1,84 @@ +name('uploader-configuration.file-namer'); + Route::get(config('backpack.base.route_prefix').'/uploader-configuration/invalid-file-namer-class', [self::class, 'invalidFileNamerClass'])->name('uploader-configuration.file-namer-class'); + Route::post(config('backpack.base.route_prefix').'/uploader-configuration/custom-uploader', [self::class, 'customUploader'])->name('uploader-configuration.custom-uploader'); + Route::post(config('backpack.base.route_prefix').'/uploader-configuration/custom-invalid-uploader', [self::class, 'customInvalidUploader'])->name('uploader-configuration.custom-invalid-uploader'); + Route::get(config('backpack.base.route_prefix').'/uploader-configuration/set-temporary-options', [self::class, 'temporaryOptions'])->name('uploader-configuration.temporary-options'); + } + + protected function setupCreateOperation() + { + //CRUD::setValidation(UploaderRequest::class); + + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'path' => 'test']); + CRUD::field('upload_multiple')->type('upload_multiple')->withFiles(['disk' => 'uploaders']); + } + + protected function setupUpdateOperation() + { + $this->setupCreateOperation(); + } + + public function setupDeleteOperation() + { + $this->setupCreateOperation(); + } + + protected function invalidFileNamer() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'fileNamer' => 'invalid']); + + return $this->store(); + } + + protected function invalidFileNamerClass() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'fileNamer' => \Backpack\CRUD\Tests\config\Models\User::class]); + + return $this->store(); + } + + protected function customUploader() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'uploader' => \Backpack\CRUD\Tests\config\Uploads\CustomUploader::class]); + + return $this->store(); + } + + protected function customInvalidUploader() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'uploader' => 'InvalidUploader']); + + return $this->store(); + } + + protected function temporaryOptions() + { + CRUD::field('upload')->type('upload')->withFiles(['disk' => 'uploaders', 'temporary' => true]); + + return $this->store(); + } +} diff --git a/tests/config/Http/Controllers/UploaderCrudController.php b/tests/config/Http/Controllers/UploaderCrudController.php new file mode 100644 index 0000000000..db146c0b4f --- /dev/null +++ b/tests/config/Http/Controllers/UploaderCrudController.php @@ -0,0 +1,39 @@ +type('upload')->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + CRUD::field('upload_multiple')->type('upload_multiple')->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + } + + protected function setupUpdateOperation() + { + $this->setupCreateOperation(); + } + + public function setupDeleteOperation() + { + $this->setupCreateOperation(); + } +} diff --git a/tests/config/Http/Controllers/UploaderValidationCrudController.php b/tests/config/Http/Controllers/UploaderValidationCrudController.php new file mode 100644 index 0000000000..dcb402c11a --- /dev/null +++ b/tests/config/Http/Controllers/UploaderValidationCrudController.php @@ -0,0 +1,44 @@ +type('upload') + ->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + CRUD::field('upload_multiple') + ->type('upload_multiple') + ->withFiles(['disk' => 'uploaders', 'fileNamer' => fn ($value) => $value->getClientOriginalName()]); + } + + protected function setupUpdateOperation() + { + $this->setupCreateOperation(); + } + + public function setupDeleteOperation() + { + $this->setupCreateOperation(); + } +} diff --git a/tests/config/Http/Requests/UploaderRequest.php b/tests/config/Http/Requests/UploaderRequest.php new file mode 100644 index 0000000000..6ebd058be4 --- /dev/null +++ b/tests/config/Http/Requests/UploaderRequest.php @@ -0,0 +1,34 @@ + ValidUpload::field('required')->file(['mimes:jpg', 'max:100']), + 'upload_multiple' => ValidUploadMultiple::field(['required', 'min:2'])->file(['mimes:jpg', 'max:100']), + ]; + } +} diff --git a/tests/config/Models/FakeUploader.php b/tests/config/Models/FakeUploader.php new file mode 100644 index 0000000000..501a7b73cc --- /dev/null +++ b/tests/config/Models/FakeUploader.php @@ -0,0 +1,14 @@ + 'array', + ]; + + protected $fakeColumns = ['extras']; +} diff --git a/tests/config/Models/Uploader.php b/tests/config/Models/Uploader.php new file mode 100644 index 0000000000..e2e94a9672 --- /dev/null +++ b/tests/config/Models/Uploader.php @@ -0,0 +1,23 @@ + 'json', + ]; + + public $timestamps = false; +} diff --git a/tests/config/Models/User.php b/tests/config/Models/User.php index bb3ccbc3b9..427f3ee21a 100644 --- a/tests/config/Models/User.php +++ b/tests/config/Models/User.php @@ -3,9 +3,10 @@ namespace Backpack\CRUD\Tests\Config\Models; use Backpack\CRUD\app\Models\Traits\CrudTrait; -use Illuminate\Database\Eloquent\Model; +use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Foundation\Auth\User as Authenticatable; -class User extends Model +class User extends Authenticatable implements MustVerifyEmail { use CrudTrait; diff --git a/tests/config/Uploads/CustomUploader.php b/tests/config/Uploads/CustomUploader.php new file mode 100644 index 0000000000..e2111df44d --- /dev/null +++ b/tests/config/Uploads/CustomUploader.php @@ -0,0 +1,9 @@ +bigIncrements('id')->unsigned(); + $table->string('upload')->nullable(); + $table->string('image')->nullable(); + $table->json('upload_multiple')->nullable(); + $table->json('dropzone')->nullable(); + $table->json('easymde')->nullable(); + $table->json('repeatable')->nullable(); + $table->json('extras')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('uploaders'); + } +}; diff --git a/tests/config/views/admin_layout.blade.php b/tests/config/views/admin_layout.blade.php new file mode 100644 index 0000000000..67fa4c5860 --- /dev/null +++ b/tests/config/views/admin_layout.blade.php @@ -0,0 +1,59 @@ + + + + + + + + @if (backpack_theme_config('meta_robots_content')) + + @endif + + {{-- Encrypted CSRF token for Laravel, in order for Ajax requests to work --}} + {{ isset($title) ? $title.' :: '.backpack_theme_config('project_name') : backpack_theme_config('project_name') }} + + @yield('before_styles') + @stack('before_styles') + @basset('https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css', true, [ + 'integrity' => 'sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65', + 'crossorigin' => 'anonymous', + ]) + @yield('after_styles') + @stack('after_styles') + + + + +
+ +
+ +
+
+ + @yield('before_breadcrumbs_widgets') + @yield('after_breadcrumbs_widgets') + @yield('header') + +
+ @yield('before_content_widgets') + @yield('content') + @yield('after_content_widgets') +
+
+
+
+
+ +@yield('before_scripts') +@stack('before_scripts') + +@basset('https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js', true, [ + 'integrity' => 'sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4', + 'crossorigin' => 'anonymous', +]) + +@yield('after_scripts') +@stack('after_scripts') + + diff --git a/tests/config/views/blank.blade.php b/tests/config/views/blank.blade.php new file mode 100644 index 0000000000..2604c4913f --- /dev/null +++ b/tests/config/views/blank.blade.php @@ -0,0 +1,5 @@ + +@extends(backpack_view('admin_layout')) + +@section('content') +@endsection \ No newline at end of file