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
25 changes: 22 additions & 3 deletions modules/backend/behaviors/FormController.php
Original file line number Diff line number Diff line change
Expand Up @@ -668,11 +668,14 @@ public function formRenderSecondaryTabs()
* default), the controller also implements the ListController behavior, and
* an existing record is being viewed.
*
* Resolved through the controller so that `formGetRecordNavigation()` can be
* overridden there, as with the other extension points on this behavior.
*
* @return string HTML markup (empty string when navigation is unavailable)
*/
public function formRenderRecordNavigation(): string
{
$navigation = $this->formGetRecordNavigation();
$navigation = $this->controller->formGetRecordNavigation();
if ($navigation === null || $navigation['current'] === null) {
return '';
}
Expand All @@ -693,12 +696,26 @@ public function formRenderRecordNavigation(): string
* position is resolved in PHP — no driver-specific SQL — so it behaves
* identically across every database Winter supports.
*
* `recordNavigation` accepts `false` to disable navigation, or the name of a
* list definition to navigate that list instead of the primary one, and may be
* set per form context. A controller whose primary list is filtered to a subset
* -- an open queue, say -- can then still offer navigation on a context that
* views records outside it:
*
* preview:
* recordNavigation: archive
*
* @param \Winter\Storm\Database\Model|null $model
* @return array{previous: mixed, next: mixed, current: int|null, total: int}|null
*/
public function formGetRecordNavigation($model = null): ?array
{
if (!$this->getConfig('recordNavigation', true)) {
$navigation = $this->getConfig(
"{$this->context}[recordNavigation]",
$this->getConfig('recordNavigation', true)
);

if (!$navigation) {
return null;
}

Expand All @@ -712,7 +729,9 @@ public function formGetRecordNavigation($model = null): ?array
}

$this->controller->makeLists();
$listWidget = $this->controller->listGetWidget();
$listWidget = $this->controller->listGetWidget(
is_string($navigation) ? $navigation : null
);
if (!$listWidget) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

namespace Backend\Tests\Behaviors;

use Backend\Classes\Controller;
use Backend\Models\User;
use Backend\Tests\Fixtures\Models\UserFixture;
use System\Tests\Bootstrap\PluginTestCase;

/**
* Coverage for which list record navigation walks, and for overriding it.
*
* `formGetRecordNavigation()` reads its siblings from the controller's **primary**
* list. That is the right default, but it leaves navigation unavailable on any
* controller whose primary list is deliberately a subset -- a queue filtered to
* pending records, say -- because a record outside that subset has no neighbours
* in it and the buttons silently disappear.
*
* `recordNavigation` therefore also accepts the name of a list definition, and is
* resolved per form context, so a `preview` context can navigate an archive list
* while `update` keeps navigating the queue.
*
* The other half is that `formRenderRecordNavigation()` resolves the getter through
* `$this->controller`. Without that a controller cannot override
* `formGetRecordNavigation()` at all: the behavior calls its own copy, so the
* override is never reached and the only way to influence navigation is to
* reimplement the render helper verbatim.
*
* @see modules/backend/behaviors/FormController.php
*/
class NavigationController extends Controller
{
public $implement = [
\Backend\Behaviors\FormController::class,
\Backend\Behaviors\ListController::class,
];

public $formConfig = [
'name' => 'User',
'modelClass' => User::class,
'form' => ['fields' => ['login' => ['label' => 'Login']]],
'update' => ['title' => 'Edit'],
'preview' => [
'title' => 'View',
// The point of the feature: this context navigates a different list
'recordNavigation' => 'archive',
],
];

public $listConfig = [
'index' => [
'modelClass' => User::class,
'list' => ['columns' => ['login' => ['label' => 'Login']]],
],
'archive' => [
'modelClass' => User::class,
'list' => ['columns' => ['login' => ['label' => 'Login']]],
],
];

/**
* The primary list is a subset -- superusers only -- while the archive holds
* everybody. This is the shape that leaves navigation unavailable today.
*/
public function listExtendQuery($query, $definition)
{
if ($definition === 'index') {
$query->where('is_superuser', true);
}

return $query->orderBy('id');
}
}

/**
* Disables navigation outright, to prove `false` still wins.
*/
class NavigationDisabledController extends NavigationController
{
public $formConfig = [
'name' => 'User',
'modelClass' => User::class,
'form' => ['fields' => ['login' => ['label' => 'Login']]],
'preview' => ['recordNavigation' => false],
];
}

/**
* Overrides the getter, which only takes effect if the render helper resolves it
* through the controller.
*/
class NavigationOverrideController extends NavigationController
{
public array $overrideCalls = [];

public function formGetRecordNavigation($model = null): ?array
{
$this->overrideCalls[] = $model ? $model->getKey() : null;

return ['previous' => 41, 'next' => 43, 'current' => 2, 'total' => 3];
}
}

class FormControllerRecordNavigationListTest extends PluginTestCase
{
protected User $inBothLists;

protected User $archivedOnly;

public function setUp(): void
{
parent::setUp();

$this->inBothLists = (new UserFixture)->asSuperUser();
$this->inBothLists->login = 'in-both';
$this->inBothLists->email = '[email protected]';
$this->inBothLists->forceSave();

// In the archive only -- the record with no neighbours in the primary list
$this->archivedOnly = new UserFixture;
$this->archivedOnly->login = 'archive-only';
$this->archivedOnly->email = '[email protected]';
$this->archivedOnly->forceSave();

$this->actingAs((new UserFixture)->asSuperUser());
}

public function testThePrimaryListIsUsedByDefault(): void
{
$controller = new NavigationController;
$controller->initForm($this->inBothLists, 'update');

$navigation = $controller->formGetRecordNavigation($this->inBothLists);

$this->assertNotNull($navigation);
$this->assertNotNull($navigation['current'], 'the record should be found in the primary list');
}

public function testARecordOutsideThePrimaryListHasNoPositionInIt(): void
{
// The behaviour this feature exists to answer: navigation is unavailable
// because the primary list is a subset that excludes this record
$controller = new NavigationController;
$controller->initForm($this->archivedOnly, 'update');

$navigation = $controller->formGetRecordNavigation($this->archivedOnly);

$this->assertNull($navigation['current']);
$this->assertSame('', $controller->formRenderRecordNavigation());
}

public function testAContextCanNavigateANamedList(): void
{
$controller = new NavigationController;
$controller->initForm($this->archivedOnly, 'preview');

$navigation = $controller->formGetRecordNavigation($this->archivedOnly);

$this->assertNotNull($navigation['current'], 'the archive list contains this record');
$this->assertSame(
User::query()->count(),
$navigation['total'],
'the total should come from the archive list, not the filtered primary one'
);
}

public function testNavigationCanStillBeDisabled(): void
{
$controller = new NavigationDisabledController;
$controller->initForm($this->inBothLists, 'preview');

$this->assertNull($controller->formGetRecordNavigation($this->inBothLists));
$this->assertSame('', $controller->formRenderRecordNavigation());
}

public function testTheGetterCanBeOverriddenByTheController(): void
{
$controller = new NavigationOverrideController;
$controller->initForm($this->archivedOnly, 'update');

// Reached directly ...
$this->assertSame(2, $controller->formGetRecordNavigation($this->archivedOnly)['current']);

// ... and, the half that did not work, through the render helper. Without the
// controller resolving the getter this renders nothing, because the behavior
// calls its own copy and finds no position in the primary list.
$this->assertNotSame('', $controller->formRenderRecordNavigation());
$this->assertNotEmpty($controller->overrideCalls);
}
}
Loading