Skip to content
Open
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
95 changes: 84 additions & 11 deletions src/BootstrapRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Nette\Forms\Form;
use Nette\Forms\FormRenderer;
use Nette\Utils\Html;
use SplObjectStorage;

/**
* Converts a Form into Bootstrap 4 HTML output.
Expand Down Expand Up @@ -41,7 +42,12 @@ class BootstrapRenderer implements FormRenderer
*/
protected $gridBreakPoint = 'sm';

/** @var BootstrapForm */
/**
* Not necessarily a BootstrapForm: Nette\Forms\Blueprint (the {formPrint} macro) renders
* a plain dummy form through a clone of this renderer.
*
* @var Form
*/
protected $form;

/** @var int */
Expand All @@ -56,16 +62,28 @@ class BootstrapRenderer implements FormRenderer
/** @var bool */
private $groupHidden = true;

/**
* What this renderer has already drawn in the current render.
*
* Tracked by identity rather than only through RendererOptions::_RENDERED, because the
* dummy controls Nette\Forms\Blueprint feeds us forward getOption() to the control they
* wrap, so an option written here would be read back off a different object.
*
* @var SplObjectStorage<BaseControl|BootstrapRow, null>
*/
private $renderedControls;

public function __construct(int $mode = RenderMode::VERTICAL_MODE)
{
$this->setMode($mode);
$this->renderedControls = new SplObjectStorage();
}

/**
* Sets the form for which to render. Used only if a specific function of the renderer must be executed
* outside of render(), such as during assisted manual rendering.
*/
public function attachForm(BootstrapForm $form): void
public function attachForm(Form $form): void
{
$this->form = $form;
}
Expand Down Expand Up @@ -306,8 +324,6 @@ public function setGroupHidden(bool $groupHidden): BootstrapRenderer

/**
* Provides complete form rendering.
*
* @param BootstrapForm $form
*/
public function render(Form $form): string
{
Expand All @@ -327,6 +343,8 @@ public function render(Form $form): string
*/
public function renderBegin(): string
{
$this->renderedControls = new SplObjectStorage();

foreach ($this->form->getControls() as $control) {
if ($control instanceof BaseControl || $control instanceof BootstrapRow) {
$control->setOption(RendererOptions::_RENDERED, false);
Expand Down Expand Up @@ -436,10 +454,16 @@ public function renderBody(): string
*/
public function renderControl(BaseControl $control): string
{
/** @var Html $controlHtml */
$controlHtml = $control->getControl();
$control->setOption(RendererOptions::_RENDERED, true);
if (($this->form->showValidation || $control->hasErrors()) && $control instanceof IValidationInput) {
$this->markRendered($control);

// Blueprint's dummy controls return a bare '{input ...}' placeholder, which has
// no attributes to configure — pass it through untouched
if (!$controlHtml instanceof Html) {
return (string) $controlHtml;
}

if (($this->shouldShowValidation() || $control->hasErrors()) && $control instanceof IValidationInput) {
$controlHtml = $control->showValidation($controlHtml);
}

Expand Down Expand Up @@ -468,7 +492,7 @@ public function renderControls($parent): string
continue;
}

if ($control->getOption(RendererOptions::_RENDERED)) {
if ($this->isRendered($control)) {
continue;
}

Expand Down Expand Up @@ -509,12 +533,18 @@ public function renderEnd(): string
*/
public function renderLabel(BaseControl $control): Html
{
$controlLabel = $control->getLabel();

// Blueprint's dummy controls carry no caption but do return a '{label ...}' placeholder,
// which already expands to a whole <label> — emit it raw instead of nesting it in ours
if (is_string($controlLabel) && $controlLabel !== '') {
return Html::el()->setHtml($controlLabel);
}

if ($control->getCaption() === null) {
return Html::el();
}

$controlLabel = $control->getLabel();

if ($controlLabel instanceof Html && $controlLabel->getName() === 'label') {
// the control has already provided us with the element, no need to create our own
$controlLabel = $this->configElem(Cnf::LABEL, $controlLabel);
Expand Down Expand Up @@ -604,6 +634,40 @@ public function setMode(int $renderMode): void
$this->renderMode = $renderMode;
}

/**
* Records that this renderer has drawn the control, both for itself and, as before,
* as an option other code may read.
*
* @param BaseControl|BootstrapRow $control
*/
protected function markRendered($control): void
{
$this->renderedControls->attach($control);
$control->setOption(RendererOptions::_RENDERED, true);
}

/**
* Whether the control should be skipped: either this renderer has already drawn it,
* or somebody marked it as rendered by hand.
*
* @param BaseControl|BootstrapRow $control
*/
protected function isRendered($control): bool
{
return $this->renderedControls->contains($control)
|| (bool) $control->getOption(RendererOptions::_RENDERED);
}

/**
* Whether valid controls should be explicitly marked as valid.
*
* Only a BootstrapForm knows about this, and the attached form is not always one.
*/
protected function shouldShowValidation(): bool
{
return $this->form instanceof BootstrapForm && $this->form->isShowValidation();
}

/**
* Fetch config tailored for current render mode
*
Expand Down Expand Up @@ -680,7 +744,7 @@ protected function renderFeedback(?BaseControl $control = null): ?Html
$isValid = false;
$showFeedback = true;
$messages = $control->getErrors();
} elseif ($this->form->showValidation) {
} elseif ($this->shouldShowValidation()) {
$isValid = true;
// control is valid and we want to explicitly show that it's valid
$message = $control->getOption(RendererOptions::FEEDBACK_VALID);
Expand Down Expand Up @@ -747,4 +811,13 @@ protected function renderFeedback(?BaseControl $control = null): ?Html
}
}

/**
* Blueprint renders through a clone of the form's renderer; it must not inherit
* bookkeeping from the render the original may be in the middle of.
*/
public function __clone()
{
$this->renderedControls = new SplObjectStorage();
}

}
4 changes: 4 additions & 0 deletions src/Enums/RendererOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class RendererOptions

/**
* Internal. If control has already been rendered.
*
* Deliberately NOT Nette's own 'rendered' key, which BaseControl::getControl() sets on
* every fetch: the renderer must be able to tell "I drew this" apart from "somebody
* asked for the html", or assisted manual rendering silently skips controls.
*/
public const _RENDERED = '_rendered';

Expand Down
2 changes: 1 addition & 1 deletion src/Grid/BootstrapRow.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ public function setParent(?IContainer $parent = null, ?string $name = null): sta
*/
public function getOption(string $option)
{
return $this->options[$option];
return $this->options[$option] ?? null;
}

/**
Expand Down
42 changes: 42 additions & 0 deletions src/Traits/FakeControlTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

namespace Contributte\FormsBootstrap\Traits;

use Nette\ComponentModel\Component;
use Nette\ComponentModel\IComponent;
use Nette\Forms\Control;
use Nette\NotImplementedException;
use Stringable;

/**
* Trait FakeControlTrait.
Expand Down Expand Up @@ -45,6 +48,45 @@ public function isOmitted(): bool
return true;
}

/**
* Hierarchical name of the component, the way a real Nette component would report it.
*
* Needed because tools that walk $form->getControls() -- Nette\Forms\Blueprint, i.e. the
* {formPrint} macro -- call this on everything they find, and a fake control is found too.
*
* @param class-string<IComponent>|null $type
*/
public function lookupPath(?string $type = null, bool $throw = true): ?string
{
$parent = $this->getParent();

// the searched-for ancestor is our direct parent, so the path is just our own name
if ($type !== null && $parent instanceof $type) {
return $this->getName();
}

$parentPath = $parent instanceof Component ? $parent->lookupPath($type, $throw) : null;

return ($parentPath === null || $parentPath === '' ? '' : $parentPath . IComponent::NameSeparator)
. $this->getName();
}

/**
* A fake control has no label.
*
* @param string|Stringable|null $caption
* @return null
*/
public function getLabel($caption = null)
{
return null;
}

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

/**
* Not supported
*
Expand Down
67 changes: 67 additions & 0 deletions tests/Grid/BootstrapRowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace Tests\Grid;

use ArrayObject;
use Contributte\FormsBootstrap\BootstrapForm;
use Contributte\FormsBootstrap\Grid\BootstrapCell;
use Contributte\FormsBootstrap\Grid\BootstrapRow;
use Nette\Application\UI\Presenter;
use Nette\Forms\Form;
use Nette\NotImplementedException;
use Tests\BaseTestCase;

Expand Down Expand Up @@ -113,6 +115,71 @@ public function testControlsAddedThroughCellStayReachableOnForm(): void
$this->assertSame('name', $this->form['name']->getName());
}

public function testRowHasNoLabelAndIsNeverRequired(): void
{
$this->assertNull($this->row->getLabel());
$this->assertNull($this->row->getLabel('caption'));
$this->assertFalse($this->row->isRequired());
}

public function testOptionsRoundTrip(): void
{
$this->row->setOption('description', 'Hello');

$this->assertSame('Hello', $this->row->getOption('description'));
}

public function testUnsetOptionIsNullAndDoesNotWarn(): void
{
$warnings = new ArrayObject();
set_error_handler(
static function (int $severity, string $message) use ($warnings): bool {
$warnings[] = $message;

return true;
},
E_WARNING,
);

try {
$value = $this->row->getOption('never-set');
} finally {
restore_error_handler();
}

$this->assertNull($value);
$this->assertSame([], $warnings->getArrayCopy());
}

public function testLookupPathOfRowDirectlyOnForm(): void
{
$this->assertSame($this->row->getName(), $this->row->lookupPath(Form::class));
}

public function testLookupPathOfRowInsideContainer(): void
{
$container = $this->form->addContainer('sub');
$nested = $container->addRow();

$this->assertSame('sub-' . $nested->getName(), $nested->lookupPath(Form::class));
}

/**
* The fake control must report its path exactly like a real sibling would,
* whatever the form happens to be attached to.
*/
public function testLookupPathMatchesRealControlInSamePlace(): void
{
$container = $this->form->addContainer('sub');
$nested = $container->addRow();
$real = $container->addText('x');

$expected = static fn (string $path): string => substr($path, 0, -strlen('x')) . $nested->getName();

$this->assertSame($expected($real->lookupPath(Form::class)), $nested->lookupPath(Form::class));
$this->assertSame($expected((string) $real->lookupPath()), $nested->lookupPath());
}

protected function setUp(): void
{
$this->form = new BootstrapForm();
Expand Down
Loading
Loading