Skip to content

Commit

Permalink
Merge pull request #5317 from Laravel-Backpack/columns-link-to
Browse files Browse the repository at this point in the history
[Feature Request] link() helper on CrudColumn
  • Loading branch information
pxpm committed Oct 31, 2023
2 parents 58f88a3 + caf173a commit 165e23f
Show file tree
Hide file tree
Showing 8 changed files with 254 additions and 9 deletions.
1 change: 1 addition & 0 deletions src/app/Library/CrudPanel/CrudColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* @method self priority(int $value)
* @method self key(string $value)
* @method self upload(bool $value)
* @method self linkTo(string $routeName, ?array $parameters = [])
*/
class CrudColumn
{
Expand Down
5 changes: 1 addition & 4 deletions src/app/Library/CrudPanel/Traits/Relationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,8 @@ private function modelMethodIsRelationship($model, $method)
/**
* Check if it's possible that attribute is in the relation string when
* the last part of the string is not a method on the chained relations.
*
* @param array $field
* @return bool
*/
private function isAttributeInRelationString($field)
public function isAttributeInRelationString(array $field): bool
{
if (! str_contains($field['entity'], '.')) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,18 @@ public function callRegisteredAttributeMacros(): self

foreach (array_keys($macros) as $macro) {
if (isset($attributes[$macro])) {
is_array($attributes[$macro]) ? $this->{$macro}($attributes[$macro]) : $this->{$macro}([]);
continue;
$this->{$macro}($attributes[$macro]);
}
if (isset($attributes['subfields'])) {
$subfieldsWithMacros = collect($attributes['subfields'])
->filter(fn ($item) => isset($item[$macro]));

$subfieldsWithMacros->each(
function ($item) use ($subfieldsWithMacros, $macro) {
$config = ! is_array($item[$macro]) ? [] : $item[$macro];
if ($subfieldsWithMacros->last() === $item) {
$this->{$macro}($config, $item);
$this->{$macro}($item[$macro], $item);
} else {
$this->{$macro}($config, $item, false);
$this->{$macro}($item[$macro], $item, false);
}
}
);
Expand Down
65 changes: 65 additions & 0 deletions src/macros.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
}
if (! CrudColumn::hasMacro('withFiles')) {
CrudColumn::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) {
$uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : [];
/** @var CrudField|CrudColumn $this */
RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents);

Expand All @@ -45,13 +46,77 @@

if (! CrudField::hasMacro('withFiles')) {
CrudField::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) {
$uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : [];
/** @var CrudField|CrudColumn $this */
RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents);

return $this;
});
}

if (! CrudColumn::hasMacro('linkTo')) {
CrudColumn::macro('linkTo', function (string|array|Closure $routeOrConfiguration, ?array $parameters = []): static {
$wrapper = $this->attributes['wrapper'] ?? [];

// parse the function input to get the actual route and parameters we'll be working with
if (is_array($routeOrConfiguration)) {
$route = $routeOrConfiguration['route'] ?? null;
$parameters = $routeOrConfiguration['parameters'] ?? [];
} else {
$route = $routeOrConfiguration;
}

// if the route is a closure, we'll just call it
if ($route instanceof Closure) {
$wrapper['href'] = function ($crud, $column, $entry, $related_key) use ($route) {
return $route($entry, $related_key, $column, $crud);
};
$this->wrapper($wrapper);

return $this;
}

// 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']}].");
}

// calculate the parameters we'll be using for the route() call
// (eg. if there's only one parameter and user didn't provide it, we'll assume it's the entry's related key)
$parameters = (function () use ($parameters, $routeInstance, $route) {
$expectedParameters = $routeInstance->parameterNames();

if (count($expectedParameters) === 0) {
return $parameters;
}

$autoInferedParameter = array_diff($expectedParameters, array_keys($parameters));
if (count($autoInferedParameter) > 1) {
throw new \Exception("Route [{$route}] expects parameters [".implode(', ', $expectedParameters)."]. Insuficient parameters provided in column: [{$this->attributes['name']}].");
}
$autoInferedParameter = current($autoInferedParameter) ? [current($autoInferedParameter) => function ($entry, $related_key, $column, $crud) {
$entity = $crud->isAttributeInRelationString($column) ? Str::before($column['entity'], '.') : $column['entity'];

return $related_key ?? $entry->{$entity}?->getKey();
}] : [];

return array_merge($autoInferedParameter, $parameters);
})();

// set up the wrapper href attribute
$wrapper['href'] = function ($crud, $column, $entry, $related_key) use ($route, $parameters) {
// if the parameter is callable, we'll call it
$parameters = collect($parameters)->map(fn ($item) => is_callable($item) ? $item($entry, $related_key, $column, $crud) : $item)->toArray();

return route($route, $parameters);
};

$this->wrapper($wrapper);

return $this;
});
}

/**
* The route macro allows developers to generate the routes for a CrudController,
* for all operations, using a simple syntax: Route::crud().
Expand Down
2 changes: 2 additions & 0 deletions tests/BaseTestClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ protected function setUp(): void
'prefix' => config('backpack.base.route_prefix', 'admin'),
],
function () {
Route::get('articles/{id}/show/{detail}', ['as' => 'article.show.detail', 'action' => 'Backpack\CRUD\Tests\config\Http\Controllers\ArticleCrudController@detail']);
Route::crud('users', 'Backpack\CRUD\Tests\config\Http\Controllers\UserCrudController');
Route::crud('articles', 'Backpack\CRUD\Tests\config\Http\Controllers\ArticleCrudController');
}
);
}
Expand Down
147 changes: 147 additions & 0 deletions tests/Unit/CrudPanel/CrudPanelColumnsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Backpack\CRUD\Tests\Unit\CrudPanel;

use Backpack\CRUD\app\Library\CrudPanel\CrudColumn;
use Backpack\CRUD\Tests\config\Models\Article;
use Backpack\CRUD\Tests\config\Models\User;

/**
Expand Down Expand Up @@ -633,4 +634,150 @@ public function testItCanAddAFluentColumnUsingArrayWithoutName()
$this->crudPanel->column(['type' => 'text']);
$this->assertCount(1, $this->crudPanel->columns());
}

public function testColumnLinkToThrowsExceptionWhenNotAllRequiredParametersAreFilled()
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Route [article.show.detail] expects parameters [id, detail]. Insuficient 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);
}
}
34 changes: 34 additions & 0 deletions tests/config/Http/Controllers/ArticleCrudController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Backpack\CRUD\Tests\Config\Http\Controllers;

use Backpack\CRUD\app\Http\Controllers\CrudController;
use Backpack\CRUD\Tests\config\Models\Article;

class ArticleCrudController extends CrudController
{
use \Backpack\CRUD\app\Http\Controllers\Operations\CreateOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\UpdateOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\ListOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\ShowOperation;

public function setup()
{
$this->crud->setModel(Article::class);
$this->crud->setRoute('articles');
}

public function setupUpdateOperation()
{
}

protected function create()
{
return response('create');
}

protected function detail()
{
return response('detail');
}
}
1 change: 1 addition & 0 deletions tests/config/Http/Controllers/UserCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class UserCrudController extends CrudController
use \Backpack\CRUD\app\Http\Controllers\Operations\CreateOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\UpdateOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\ListOperation;
use \Backpack\CRUD\app\Http\Controllers\Operations\ShowOperation;

public function setup()
{
Expand Down

0 comments on commit 165e23f

Please sign in to comment.