diff --git a/README.md b/README.md index 1976640..9963fb0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Filament Nested Resources allows you to create nested resources of any depth. Th ## Showcase +![Screenshot 1](docs/images/screenshot_01.png) +![Screenshot 2](docs/images/screenshot_02.png) + ## Support us Your support is key to the continual advancement of our plugin. We appreciate every user who has contributed to our journey so far. @@ -24,6 +27,136 @@ composer require guava/filament-nested-resources ``` ## Usage +Throughout the documentation I refer to `root` nested resource and `child` nested resources. The only difference is that the `Root` is the first resource in the relationship tree. + +In the examples: ArtistResource > AlbumResource > SongResource + +Artist would be the root resource, the other would be child resources. + +### TL;DR +Replace all extends of `Resource`, `RelationManager`, `Page` classes with their corresponding `Nested` variants. + +So instead of extending `Resource`, extend `NestedResource`, instead of extending `EditRecord`, extend `NestedEditRecord` and so on. + +Where applicable (in child resources), implement `getAncestor` to define the parent of the nested resource. + +Optionally remove `index` page for child nested resources. + +### Detailed usage + +In order to make to set up Nested Resources, you need to do these steps: + +1. Extend `NestedResource` on your root resource and all child resources you want to nest under your root resource. +2. On your child `NestedResources`, implement the `getAncestor()` method and return an instance of the `Ancestor` config. +3. On your **child** `NestedResource` pages, extend the corresponding `Nested[Context]Record`. So instead of `EditRecord`, extend `NestedEditRecord` and so on. +4. Create a `RelationManager` to bind the Resources together. Extend `NestedRelationManager` instead of `RelationManager`. + +Let's imagine the scenario from the Showcase screenshots, where we have this schema: +1. Artist Model. +2. Album Model (Belongs to Artist). +3. Song Model (Belongs to Album). + +First we create `ArtistResource`: + +```php +use Guava\Filament\NestedResources\Resources\NestedResource; + +class ArtistResource extends NestedResource +{ + // + + public static function getRelations(): array + { + return [ + AlbumsRelationManager::class, + ]; + } +} +``` + +Next, we create the `AlbumResource`: + +```php +use Guava\Filament\NestedResources\Resources\NestedResource; +use Guava\Filament\NestedResources\Ancestor; + +class AlbumResource extends NestedResource +{ + // + + public static function getRelations(): array + { + return [ + // Repeat the same for Song Resource + ]; + } + + public static function getAncestor() : ?Ancestor + { + // This is just a simple configuration with a few helper methods + return Ancestor::make( + ArtistResource::class, // Parent Resource Class + // Optionally you can pass a relationship name, if it's non-standard. The plugin will try to guess it otherwise + ); + } +} +``` + +For each page for our `AlbumResource`, we extend the corresponding NestedPage: + +```php +use Guava\Filament\NestedResources\Pages\NestedCreateRecord; + +class CreateAlbum extends NestedCreateRecord +{ + // +} +``` + +```php +use Guava\Filament\NestedResources\Pages\NestedEditRecord; + +class EditAlbum extends NestedEditRecord +{ + // +} +``` + +```php +use Guava\Filament\NestedResources\Pages\NestedListRecords; + +class ListAlbums extends NestedListRecords +{ + // +} +``` + +And finally we create the `AlbumsRelationManager`. We just need to extend the `NestedRelationManager` class here: + +```php +use Guava\Filament\NestedResources\RelationManagers\NestedRelationManager; + +class AlbumsRelationManager extends NestedRelationManager +{ + // +} +``` + +Optionally, we recommend deleting the `index` page from your child `NestedResources` (in this case AlbumResource): + +```php +public static function getPages(): array + { + return [ + 'create' => Pages\CreateAlbum::route('/create'), + 'edit' => Pages\EditAlbum::route('/{record}/edit'), + ]; + } +``` + +The plugin will work either way, but the difference is that the breadcrumb URLs will redirect to different pages based on whether the index page exists or not. + +If it exists, it will redirect to the index page (which might be a little confusing) and if it does NOT exist, it will redirect to the Parent Resource's Edit page. Since there's a relation manager, you will still have a list of all records. ## Contributing diff --git a/docs/images/banner.jpg b/docs/images/banner.jpg index 7bc609f..30b7095 100644 Binary files a/docs/images/banner.jpg and b/docs/images/banner.jpg differ diff --git a/docs/images/screenshot_01.png b/docs/images/screenshot_01.png new file mode 100644 index 0000000..2d68dcf Binary files /dev/null and b/docs/images/screenshot_01.png differ diff --git a/docs/images/screenshot_02.png b/docs/images/screenshot_02.png new file mode 100644 index 0000000..0d4b70b Binary files /dev/null and b/docs/images/screenshot_02.png differ diff --git a/src/Ancestor.php b/src/Ancestor.php new file mode 100644 index 0000000..b8a6862 --- /dev/null +++ b/src/Ancestor.php @@ -0,0 +1,77 @@ +resource; + } + + public function getRelationship(): string + { + if (! $this->relationship) { + return $this->getResource()::getModelLabel(); + } + + return $this->relationship; + } + + public function getRouteParameterName(): string + { + return get_resource_route_parameter($this->getResource()); + } + + public function getRouteParameters(Model $record): array + { + $ancestor = $this; + $related = $record; + + $parameters = []; + do { + // For 'create' actions, a model is not yet created, so the owner record is being sent. + // In this case, the ancestor relation and received model are offset by one level and this + // will re-sync the depth again. + if ($ancestor->getResource()::getModel() === $related::class) { + $parameters[$ancestor->getRouteParameterName()] = $related->id; + continue; + } + + $related = $ancestor->getRelatedModel($related); + $parameters[$ancestor->getRouteParameterName()] = $related->id; + } while ($ancestor = $ancestor->getResource()::getAncestor()); + + return $parameters; + } + + public function getNormalizedRouteParameters(Model $record): array + { + return array_values( + array_reverse( + $this->getRouteParameters($record) + ) + ); + } + + public function getRelatedModel(Model $record): Model + { + return $record->{$this->getRelationship()}; + } + + public static function make(string $resource, string $relationship = null) + { + return app(static::class, [ + 'resource' => $resource, + 'relationship' => $relationship, + ]); + } +} diff --git a/src/Concerns/HasAncestor.php b/src/Concerns/HasAncestor.php new file mode 100644 index 0000000..e5edf7d --- /dev/null +++ b/src/Concerns/HasAncestor.php @@ -0,0 +1,18 @@ +getAncestorRecord(); + + return parent::mutateFormDataBeforeCreate([ + ...$data, + ...$ancestor, + ]); + } + + protected function getAncestorRecord(): array + { + $id = Arr::last($this->getRouteParameterIds()); + $ancestor = static::getResource()::getAncestor(); + $record = $ancestor->getResource()::getModel()::find($id); + $fake = new (static::getModel())(); + /** @var BelongsTo $relation */ + $relation = $fake->{$ancestor->getRelationship()}(); + + return [$relation->getForeignKeyName() => $record->id]; + } + + protected function getRedirectUrlParameters(): array + { + /** @var Ancestor $ancestor */ + $ancestor = $this::getResource()::getAncestor(); + + return $ancestor->getNormalizedRouteParameters($this->getRecord()); + } +} diff --git a/src/Pages/NestedEditRecord.php b/src/Pages/NestedEditRecord.php new file mode 100644 index 0000000..44a85dd --- /dev/null +++ b/src/Pages/NestedEditRecord.php @@ -0,0 +1,35 @@ +getResource(); + + $action + ->authorize($resource::canDelete($this->getRecord())) + ->successRedirectUrl( + $resource::hasPage('index') + ? $resource::getUrl('index') + : $ancestorResource::getUrl('edit', [ + ...$ancestor->getNormalizedRouteParameters($this->getRecord()), + ]) + ) + ; + } +} diff --git a/src/Pages/NestedListRecords.php b/src/Pages/NestedListRecords.php new file mode 100644 index 0000000..718487f --- /dev/null +++ b/src/Pages/NestedListRecords.php @@ -0,0 +1,30 @@ +recordUrl(fn (Model $record) => static::getResource()::getUrl('edit', [ + ...static::getResource()::getAncestor()->getNormalizedRouteParameters($record), + 'record' => $record, + ])) + ; + } + + protected function configureCreateAction(Tables\Actions\CreateAction | CreateAction $action): void + { + parent::configureCreateAction($action); + $action->url(static::getResource()::getUrl('create', $this->getRouteParameterIds())); + } +} diff --git a/src/Pages/NestedPage.php b/src/Pages/NestedPage.php index 657541b..6955a3e 100644 --- a/src/Pages/NestedPage.php +++ b/src/Pages/NestedPage.php @@ -2,128 +2,27 @@ namespace Guava\Filament\NestedResources\Pages; -use Filament\Actions\Action; -use Filament\Actions\CreateAction; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\CreateRecord; -use Guava\Filament\NestedResources\Resources\NestedResource; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Arr; -use Livewire\Livewire; - -use function Guava\Filament\NestedResources\get_resource_route_parameter; - trait NestedPage { - /** - * @var Model|null - */ - public $ownerRecord = null; - - public array $arguments = []; - - protected function resolveArguments(array $arguments) - { - if (! ($this instanceof CreateRecord)) { - return []; - } - /** @var NestedResource $resource */ - $resource = static::getResource(); - - $arguments = Arr::mapWithKeys(array_reverse($arguments), function ($id) use (&$resource) { - $resource = $resource::getParentResource(); - $parameter = get_resource_route_parameter($resource); - - return [$parameter => $id]; - }); - - $this->ownerRecord = static::getResource()::getParentResource()::getModel()::find( - Arr::first($arguments) - ); - - return array_reverse($arguments); - } + public array $routeParameterIds = []; public function mountNestedPage(): void { - $this->arguments = $this->resolveArguments(func_get_args()); + $this->routeParameterIds = func_get_args(); } - protected function getRedirectUrl(): string + public function getRouteParameterIds(): array { - if ($this instanceof CreateRecord) { - return static::getResource()::getParentResource()::getUrl('edit', array_reverse(array_values($this->getRouteParameters()))); - } else { - return parent::getRedirectUrl(); - } + return $this->routeParameterIds; } - protected function configureAction(Action $action): void + public function getBreadcrumbs(): array { - // dd(static::getResource(),static::getResource()::getRouteParameterNames(), $this->getRouteParameters()); - parent::configureAction($action); - - match (true) { - $action instanceof CreateAction => $action->successRedirectUrl(function (Livewire $livewire) { - // dd('RECORD: ' . $livewire->getRecord()); - return 'http://www.google.com'; - }), - $action instanceof DeleteAction => $action->successRedirectUrl(static::getResource()::getParentResource()::getUrl('edit', array_reverse(array_values($this->getRouteParameters())))), - // $action instanceof ForceDeleteAction => $this->configureForceDeleteAction($action), - // $action instanceof ReplicateAction => $this->configureReplicateAction($action), - // $action instanceof RestoreAction => $this->configureRestoreAction($action), - // $action instanceof ViewAction => $this->configureViewAction($action), - default => null, - }; + return static::getResource()::getBreadcrumbs($this); } - // protected function configureDeleteAction(DeleteAction $action): void - // { - // parent::configureDeleteAction($action); // TODO: Change the autogenerated stub - // } - protected function getRouteParameters(): array - { - if ($this instanceof CreateRecord) { - - } - - $record = $this->getRecord(); - - // dd($record, static::getResource()::getRouteParameterNames()); - $resource = static::getResource(); - - return Arr::mapWithKeys(static::getResource()::getRouteParameterNames(), function (string $parameter) use (&$resource, &$record) { - - if ($record && in_array(NestedResource::class, class_parents($resource))) { - $relationship = $resource::getRelationshipName(); - $record = $record->$relationship; - // dd($resource, $parameter, $relationship, $record); - } else { - $record = $this->ownerRecord; - } - $result = [$parameter => $record->id]; - $resource = $resource::getParentResource(); - - return $result; - }); - } - - protected function createRecordAndCallHooks(array $data): void - { - if ($this->ownerRecord) { - $key = str('') - //TODO: Test with Translatable -// ->when( -// in_array(CreateRecord\Concerns\Translatable::class, class_uses($this)), -// fn (Stringable $stringable) => $stringable->append(array_key_first($data), '.'), -// ) - ->append(static::getResource()::getRelationshipName().'_id') - ->toString() - ; - - data_set($data, $key, $this->ownerRecord->id); - } + // protected function resolveRoutePathRecords(array $arguments) { + // + // } - parent::createRecordAndCallHooks($data); - } } diff --git a/src/RelationManagers/NestedRelationManager.php b/src/RelationManagers/NestedRelationManager.php index 009daca..915fe7a 100644 --- a/src/RelationManagers/NestedRelationManager.php +++ b/src/RelationManagers/NestedRelationManager.php @@ -4,23 +4,21 @@ use Filament\Facades\Filament; use Filament\Resources\RelationManagers\RelationManager; -use Filament\Resources\Resource; use Filament\Tables; -use Guava\Filament\NestedResources\Resources\NestedResource; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Arr; -abstract class NestedRelationManager extends RelationManager +class NestedRelationManager extends RelationManager { - // public static function resource(): string | Resource; - - protected static ?string $resource = null; + protected function getResource(): string + { + return Filament::getModelResource($this->getRelationship()->getRelated()); + } protected function configureCreateAction(Tables\Actions\CreateAction $action): void { parent::configureCreateAction( - $action->url($this->getResource()::getUrl('create', [ - ...array_reverse(array_values($this->getRouteParameters())), + $action->url(static::getResource()::getUrl('create', [ + ...static::getResource()::getAncestor()->getNormalizedRouteParameters($this->getOwnerRecord()), ])) ); } @@ -28,35 +26,10 @@ protected function configureCreateAction(Tables\Actions\CreateAction $action): v protected function configureEditAction(Tables\Actions\EditAction $action): void { parent::configureEditAction( - $action->url(fn (Model $record) => $this->getResource()::getUrl('edit', [ - ...$this->getRouteParameters(), + $action->url(fn (Model $record) => static::getResource()::getUrl('edit', [ + ...static::getResource()::getAncestor()->getNormalizedRouteParameters($record), 'record' => $record, ])) ); } - - protected function getRouteParameters(): array - { - $record = $this->getOwnerRecord(); - - return Arr::mapWithKeys( - $this->getResource()::getRouteParameterNames(), - function (string $parameter) use (&$record) { - $result = [$parameter => $record->id]; - $resource = $this->getResource()::getParentResource(); - - if (in_array(NestedResource::class, class_parents($resource))) { - $relationship = $resource::getRelationshipName(); - $record = $record->$relationship; - } - - return $result; - } - ); - } - - protected function getResource(): string | Resource | NestedResource - { - return static::$resource ?? Filament::getModelResource($this->getRelationship()->getRelated()); - } } diff --git a/src/Resources/Concerns/HasParentResource.php b/src/Resources/Concerns/HasParentResource.php deleted file mode 100644 index ae75405..0000000 --- a/src/Resources/Concerns/HasParentResource.php +++ /dev/null @@ -1,19 +0,0 @@ -getOwnerRecord()->{static::getRelationshipName()}(); - } - - public static function getRelationshipName(): string - { - return static::$relationship; - } -} diff --git a/src/Resources/NestedResource.php b/src/Resources/NestedResource.php index cb4abf7..96190e4 100644 --- a/src/Resources/NestedResource.php +++ b/src/Resources/NestedResource.php @@ -2,40 +2,108 @@ namespace Guava\Filament\NestedResources\Resources; +use Filament\Resources\Pages\ListRecords; +use Filament\Resources\Pages\Page; use Filament\Resources\Resource; -use Guava\Filament\NestedResources\Resources\Concerns\HasParentResource; -use Guava\Filament\NestedResources\Resources\Concerns\HasRelationship; +use Guava\Filament\NestedResources\Ancestor; +use Guava\Filament\NestedResources\Concerns\HasAncestor; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; -use function Guava\Filament\NestedResources\get_resource_route_parameter; - -class NestedResource extends Resource +abstract class NestedResource extends Resource { - use HasParentResource; - use HasRelationship; + use HasAncestor; + + public static function getSlug(): string + { + if ($ancestor = static::getAncestor()) { + $resource = $ancestor->getResource(); + $parameter = $ancestor->getRouteParameterName(); + + return $resource::getSlug() . '/{' . $parameter . '?}/' . parent::getSlug(); + } + + return parent::getSlug(); + } public static function shouldRegisterNavigation(): bool { - return false; + if (static::hasAncestor()) { + return false; + } + + return parent::shouldRegisterNavigation(); } - public static function getRouteParameterNames(): array + public static function getBreadcrumbs(Page $page, Model $record = null): array { - $parameters = []; $resource = static::class; - do { - $resource = $resource::getParentResource(); - $parameters[] = get_resource_route_parameter($resource); - } while (in_array(NestedResource::class, class_parents($resource))); + $breadcrumb = $page->getBreadcrumb(); + /** @var Ancestor $ancestor */ + $ancestor = $resource::getAncestor(); - return $parameters; - } + // If no record passed + if (! ($page instanceof ListRecords)) { + $record ??= $page->getRecord(); + } - public static function getSlug(): string - { - $resource = static::getParentResource(); - $parameter = get_resource_route_parameter($resource); + // If page has no record (such as create pages) + $id = Arr::last($page->getRouteParameterIds()); + if ($ancestor) { + $relatedRecord = $record ? $ancestor->getRelatedModel($record) : $ancestor->getResource()::getModel()::find($id); + } + + if ($ancestor) { + $index = $resource::hasPage('index') + ? [ + $resource::getUrl('index', [ + ...$ancestor->getNormalizedRouteParameters($record ?? $relatedRecord), + ]) => $resource::getBreadcrumb(), + ] + : [ + $ancestor->getResource()::getUrl('edit', [ + ...$ancestor->getNormalizedRouteParameters($record ?? $relatedRecord), + ]) . '#relation-manager' => $resource::getBreadcrumb(), + ]; + + } else { + $index = [$resource::getUrl('index') => $resource::getBreadcrumb()]; + } + + $breadcrumbs = []; + + if ($ancestor) { + $breadcrumbs = [ + ...$ancestor->getResource()::getBreadcrumbs($page, $relatedRecord), + ...$breadcrumbs, + ]; + } + + $breadcrumbs = [ + ...$breadcrumbs, + ...$index, + ]; + + if ($page::getResource() === $resource) { + $breadcrumbs = [ + ...$breadcrumbs, + ...(filled($breadcrumb) ? [$breadcrumb] : []), + ]; + } else { + // $breadcrumbs = [ + // ...$breadcrumbs, + // ...(filled($breadcrumb) ? [$breadcrumb.'2'] : []), + // ]; + $breadcrumbs = [ + ...$breadcrumbs, + $resource::getUrl('edit', [ + ...$ancestor ? $ancestor->getNormalizedRouteParameters($record) : [], + 'record' => $record, + ]) => $record->id, + ]; + } - return $resource::getSlug().'/{'.$parameter.'?}/'.parent::getSlug(); + return $breadcrumbs; } } diff --git a/src/helpers.php b/src/helpers.php index 8ced43b..1707e9b 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -3,14 +3,13 @@ namespace Guava\Filament\NestedResources; use Filament\Resources\Resource; -use Illuminate\Database\Eloquent\Model; - use function Filament\Support\get_model_label; +use Illuminate\Database\Eloquent\Model; if (! function_exists('Guava\Filament\NestedResources\get_model_route_parameter')) { function get_model_route_parameter(string | Model $model): string { - return get_model_label(is_string($model) ? $model : $model::class).'Record'; + return get_model_label(is_string($model) ? $model : $model::class) . 'Record'; } }