Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 7 additions & 34 deletions src/Resources/BuildoraResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@
use Ginkelsoft\Buildora\Actions\BulkAction;
use Ginkelsoft\Buildora\Exceptions\BuildoraException;
use Ginkelsoft\Buildora\Resources\Concerns\HasResourceActions;
use Ginkelsoft\Buildora\Resources\Concerns\HasResourceNavigation;
use Illuminate\Database\Eloquent\Model;
use Ginkelsoft\Buildora\Fields\Field;
use Exception;

/**
* Abstract base class for all Buildora Resources.
*
* Decomposition: concerns that have a self-contained surface are pulled
* out into traits in Resources\Concerns. See #135 for the long-term plan;
* the action surface is the first concern to move.
* Concerns are pulled into traits under Resources\\Concerns as part of #135.
* This file should shrink over time; new methods belong in a trait by topic.
*/
abstract class BuildoraResource
{
use HasResourceActions;
use HasResourceNavigation;

protected ?Model $parentModel = null;
protected string $modelClass;
Expand All @@ -42,28 +43,8 @@ public function __construct()
FieldValidator::validate($this->fields, $modelInstance);
}

public function title(): string
{
return class_basename($this->modelClass);
}

/**
* Configuratie voor zoekresultaten.
*
* @return array{label: string|array|callable, columns: string[]}
*/
public function searchResultConfig(): array
{
return [
'label' => ['voornaam', 'achternaam'],
'columns' => ['voornaam', 'achternaam', 'emailadres'],
];
}

public function showInNavigation(): bool
{
return true;
}
// title(), searchResultConfig(), showInNavigation() live in
// HasResourceNavigation (Resources\Concerns\HasResourceNavigation).

/**
* Create a new static instance of the resource.
Expand Down Expand Up @@ -213,15 +194,7 @@ public function getModelClass(): string
return $this->modelClass;
}

/**
* Generate a slug for the resource based on its class name.
*
* @return string
*/
public static function slug(): string
{
return str_replace('buildora', '', strtolower(class_basename(static::class)));
}
// slug() lives in HasResourceNavigation.

/**
* Return the query builder for this resource WITHOUT eager-loading relations.
Expand Down
74 changes: 74 additions & 0 deletions src/Resources/Concerns/HasResourceNavigation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Ginkelsoft\Buildora\Resources\Concerns;

/**
* Resource navigation/identification surface, extracted from BuildoraResource
* as the second decomposition step for #135.
*
* Houses the four overridable hooks that decide:
* - how the resource identifies itself in the URL (slug)
* - how it labels itself in the admin nav and breadcrumbs (title)
* - whether it shows up in the nav at all (showInNavigation)
* - what the global search uses to label and search across rows
* (searchResultConfig)
*
* Like HasResourceActions, this is a trait rather than a value object so
* that the subclass-overridable hook semantics stay intact — a consumer's
* UserBuildora overrides title()/searchResultConfig() and expects \$this
* dispatch to find them.
*/
trait HasResourceNavigation
{
/**
* Human-readable label for the resource. Defaults to the model class
* basename.
*/
public function title(): string
{
return class_basename($this->modelClass);
}

/**
* Configuration consumed by GlobalSearchController. The 'label' may be
* a column name, an array of column names (concatenated), or a callable
* receiving the model instance. 'columns' lists the columns scanned for
* `LIKE %term%` matches.
*
* The defaults below are tuned to the package's built-in UserBuildora;
* every other resource should override.
*
* @return array{label: string|array|callable, columns: string[]}
*/
public function searchResultConfig(): array
{
return [
'label' => ['voornaam', 'achternaam'],
'columns' => ['voornaam', 'achternaam', 'emailadres'],
];
}

/**
* Whether to include this resource in the admin navigation. Subclasses
* return false to keep a resource available via direct URLs (e.g. for
* relation panels) without surfacing it in the menu.
*/
public function showInNavigation(): bool
{
return true;
}

/**
* URL slug derived from the class basename:
* `App\\Buildora\\Resources\\CouponBuildora` → 'coupon'
*
* Buildora routes match resources on this value, and Spatie permissions
* follow the {slug}.{verb} convention (see BuildoraAbility), so changing
* this mid-flight requires migrating both routes and the permissions
* table — override with care.
*/
public static function slug(): string
{
return str_replace('buildora', '', strtolower(class_basename(static::class)));
}
}
141 changes: 141 additions & 0 deletions tests/Unit/Resources/Concerns/HasResourceNavigationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

namespace Ginkelsoft\Buildora\Tests\Unit\Resources\Concerns;

use Ginkelsoft\Buildora\Resources\BuildoraResource;
use Ginkelsoft\Buildora\Resources\Concerns\HasResourceNavigation;
use Ginkelsoft\Buildora\Tests\TestCase;
use Ginkelsoft\Buildora\Traits\HasBuildora;
use Illuminate\Database\Eloquent\Model;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;

class RNCustomer extends Model
{
use HasBuildora;
protected $table = 'rn_customers';
protected $guarded = [];
public $timestamps = false;
}

class CustomerBuildora extends BuildoraResource
{
public static function modelClass(): string
{
return RNCustomer::class;
}

public function defineFields(): array
{
return [];
}
}

class HiddenFromNavigationResource extends BuildoraResource
{
public static function modelClass(): string
{
return RNCustomer::class;
}

public function defineFields(): array
{
return [];
}

public function showInNavigation(): bool
{
return false;
}
}

class HasResourceNavigationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

\Illuminate\Support\Facades\Schema::create('rn_customers', function ($t) {
$t->increments('id');
$t->string('name')->nullable();
});
}

#[Test]
public function default_title_derives_from_the_model_class_basename(): void
{
$resource = new CustomerBuildora();

$this->assertSame('RNCustomer', $resource->title());
}

#[Test]
public function default_show_in_navigation_is_true(): void
{
$resource = new CustomerBuildora();

$this->assertTrue($resource->showInNavigation());
}

#[Test]
public function subclass_can_opt_out_of_navigation(): void
{
$resource = new HiddenFromNavigationResource();

$this->assertFalse($resource->showInNavigation());
}

#[Test]
public function slug_strips_the_buildora_suffix_and_lowercases(): void
{
$this->assertSame('customerbuildora', strtolower('CustomerBuildora'));
// CustomerBuildora -> 'customer' after str_replace('buildora', '', lower)
$this->assertSame('customer', CustomerBuildora::slug());
}

#[Test]
public function default_search_result_config_has_label_and_columns_keys(): void
{
$config = (new CustomerBuildora())->searchResultConfig();

$this->assertArrayHasKey('label', $config);
$this->assertArrayHasKey('columns', $config);
$this->assertIsArray($config['columns']);
}

#[Test]
public function buildora_resource_uses_the_navigation_trait(): void
{
$traits = (new ReflectionClass(BuildoraResource::class))->getTraitNames();

$this->assertContains(HasResourceNavigation::class, $traits);
}

#[Test]
public function navigation_methods_are_no_longer_inlined_in_buildora_resource(): void
{
$resourceSource = file_get_contents(
(new ReflectionClass(BuildoraResource::class))->getFileName()
);

foreach (['title', 'searchResultConfig', 'showInNavigation', 'slug'] as $movedMethod) {
$this->assertStringNotContainsString(
"function {$movedMethod}(",
$resourceSource,
"Method '{$movedMethod}' has been re-inlined into BuildoraResource.php — it should live in HasResourceNavigation trait."
);
}

$traitSource = file_get_contents(
(new ReflectionClass(HasResourceNavigation::class))->getFileName()
);

foreach (['title', 'searchResultConfig', 'showInNavigation', 'slug'] as $movedMethod) {
$this->assertStringContainsString(
"function {$movedMethod}(",
$traitSource,
"Method '{$movedMethod}' must be declared in HasResourceNavigation."
);
}
}
}
Loading