Skip to content

Commit cd25d3a

Browse files
committed
FormNode: {form detached name} for layouts that nest other forms
HTML forbids nested <form> tags, so a layout embedding a component that renders its own <form> can't wrap its controls in another <form>. The detached keyword emits an empty <form>...</form> at the opening position and links every control back to it via the HTML5 form= attribute (applied in Runtime::item()), so inputs submit correctly wherever they sit in the DOM. Controls in nested containers and {form scope} inherit the same link. Unlike {form scope}/{formContext}, detached does render a <form>, so it still accepts arguments, which apply to that tag. Throws InvalidStateException when detached is used with a non-Form Container or a form without an id.
1 parent ed9ad3b commit cd25d3a

10 files changed

Lines changed: 323 additions & 4 deletions

File tree

src/Bridges/FormsLatte/Nodes/FormNode.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919

2020

2121
/**
22-
* {form [scope] name [, attributes]} ... {/form}
22+
* {form [scope|detached] name [, attributes]} ... {/form}
2323
* {formContext name} ... {/formContext}
2424
* Renders form tags and initializes form context.
2525
*/
2626
class FormNode extends StatementNode
2727
{
2828
private const ModeLegacyScope = 'context';
2929
private const ModeScope = 'scope';
30+
private const ModeDetached = 'detached';
3031

3132
public ExpressionNode $name;
3233
public ArrayNode $attributes;
@@ -46,7 +47,7 @@ public static function create(Tag $tag): \Generator
4647
$node = $tag->node = new static;
4748
$node->mode = match (true) {
4849
$tag->name === 'formContext' => self::ModeLegacyScope,
49-
in_array($tag->parser->stream->tryPeek()?->text, [self::ModeScope], strict: true) => $tag->parser->stream->consume()->text,
50+
in_array($tag->parser->stream->tryPeek()?->text, [self::ModeScope, self::ModeDetached], strict: true) => $tag->parser->stream->consume()->text,
5051
default => null,
5152
};
5253
$node->name = $tag->parser->parseUnquotedStringOrExpression();
@@ -55,7 +56,7 @@ public static function create(Tag $tag): \Generator
5556
trigger_error("Missing comma before arguments in {{$tag->name}} tag $position.", E_USER_DEPRECATED);
5657
}
5758
$node->attributes = $tag->parser->parseArguments();
58-
if ($node->mode !== null && $node->attributes->items) {
59+
if ($node->mode !== null && $node->mode !== self::ModeDetached && $node->attributes->items) {
5960
$label = '{' . $tag->name . ($node->mode === self::ModeScope ? ' scope' : '') . '}';
6061
throw new CompileException("Arguments are not allowed in $label because it does not render a <form> tag.", $tag->position);
6162
}
@@ -81,9 +82,11 @@ public function print(PrintContext $context): string
8182
$this->name instanceof StringNode => '$this->global->uiControl[%node]',
8283
default => '(is_object($ʟ_tmp = %node) ? $ʟ_tmp : $this->global->uiControl[$ʟ_tmp])',
8384
})
85+
. ($this->mode === self::ModeDetached ? ', detached: true' : '')
8486
. ') %line;'
8587
. (match ($this->mode) {
8688
self::ModeScope, self::ModeLegacyScope => ' %3.node ',
89+
self::ModeDetached => $renderBegin . $renderEnd . ' %3.node ',
8790
default => $renderBegin . ' %3.node ' . $renderEnd,
8891
})
8992
. '$this->global->forms->end();'

src/Bridges/FormsLatte/Runtime.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class Runtime
2525
/** @var Container[] */
2626
private array $stack = [];
2727

28+
/** @var list<?string> parallel to $stack; non-null element = id of a detached form */
29+
private array $detachedIds = [];
30+
2831

2932
/**
3033
* Renders form begin.
@@ -93,11 +96,16 @@ public function get(object|string|int $item, string $type = Control::class): Con
9396
if (!$item instanceof $type) {
9497
throw new Nette\InvalidArgumentException("Expected instance of $type, " . get_debug_type($item) . ' given.');
9598
}
99+
100+
$detachedId = end($this->detachedIds);
101+
if ($detachedId !== null && $item instanceof Nette\Forms\Controls\BaseControl) {
102+
$item->setHtmlAttribute('form', $detachedId);
103+
}
96104
return $item;
97105
}
98106

99107

100-
public function begin(Container $form): void
108+
public function begin(Container $form, bool $detached = false): void
101109
{
102110
$this->stack[] = $form;
103111

@@ -107,12 +115,26 @@ public function begin(Container $form): void
107115
$control->setOption('rendered', false);
108116
}
109117
}
118+
119+
// sub-containers inherit the parent's detached id; a nested Form starts fresh
120+
$detachedId = $form instanceof Form ? null : (end($this->detachedIds) ?: null);
121+
if ($detached) {
122+
if (!$form instanceof Form) {
123+
throw new Nette\InvalidStateException('Detached mode requires a Form instance.');
124+
}
125+
$detachedId = (string) $form->getElementPrototype()->id;
126+
if ($detachedId === '') {
127+
throw new Nette\InvalidStateException('Detached form must have an id; pass a name to the Form constructor or set it via getElementPrototype()->id.');
128+
}
129+
}
130+
$this->detachedIds[] = $detachedId;
110131
}
111132

112133

113134
public function end(): void
114135
{
115136
array_pop($this->stack);
137+
array_pop($this->detachedIds);
116138
}
117139

118140

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<form action="" method="post" id="frm-main"><input type="hidden" name="_form_" value="main"></form>
2+
3+
<input type="text" name="name" form="frm-main" id="frm-main-name">
4+
5+
<input type="text" name="address[street]" form="frm-main" id="frm-main-address-street">
6+
7+
8+
9+
10+
11+
<input type="text" name="title" id="frm-wrap-title">
12+
<form action="" method="post" id="frm-side"><input type="hidden" name="_form_" value="side"></form>
13+
14+
<input type="text" name="note" form="frm-side" id="frm-side-note">
15+
16+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php %A%
2+
$this->global->forms->begin($form = $this->global->uiControl['main'], detached: true) /* pos 4:1 */;
3+
echo $this->global->forms->renderFormBegin([]) /* pos 4:1 */;
4+
echo $this->global->forms->renderFormEnd() /* pos 9:1 */;
5+
echo '
6+
';
7+
echo $this->global->forms->get('name')->getControl() /* pos 5:2 */;
8+
echo '
9+
';
10+
$this->global->forms->begin($form = (is_object($ʟ_tmp = 'address') ? $ʟ_tmp : ($this->global->forms->isNested() ? $this->global->forms->get($ʟ_tmp, Nette\Forms\Container::class) : $this->global->uiControl[$ʟ_tmp]))) /* pos 6:2 */;
11+
echo '
12+
';
13+
echo $this->global->forms->get('street')->getControl() /* pos 7:3 */;
14+
echo '
15+
';
16+
$this->global->forms->end();
17+
18+
echo "\n";
19+
$this->global->forms->end();
20+
21+
echo '
22+
23+
24+
';
25+
$this->global->forms->begin($form = (is_object($ʟ_tmp = 'wrap') ? $ʟ_tmp : ($this->global->forms->isNested() ? $this->global->forms->get($ʟ_tmp, Nette\Forms\Container::class) : $this->global->uiControl[$ʟ_tmp]))) /* pos 16:1 */;
26+
echo '
27+
';
28+
echo $this->global->forms->get('title')->getControl() /* pos 17:2 */;
29+
echo '
30+
';
31+
$this->global->forms->begin($form = $this->global->uiControl['side'], detached: true) /* pos 18:2 */;
32+
echo $this->global->forms->renderFormBegin([]) /* pos 18:2 */;
33+
echo $this->global->forms->renderFormEnd() /* pos 20:2 */;
34+
echo '
35+
';
36+
echo $this->global->forms->get('note')->getControl() /* pos 19:3 */;
37+
echo '
38+
';
39+
$this->global->forms->end();
40+
41+
echo "\n";
42+
$this->global->forms->end();
43+
44+
echo "\n";
45+
}
46+
%A%
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<form action="" method="post" id="frm-myForm"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
2+
3+
<div class="layout">
4+
<label for="frm-myForm-name">Name:</label>
5+
<input type="text" name="name" form="frm-myForm" id="frm-myForm-name">
6+
<input type="text" name="email" form="frm-myForm" id="frm-myForm-email">
7+
<input type="submit" name="_submit" form="frm-myForm" value="Save">
8+
</div>
9+
10+
11+
12+
<form action="" method="post" id="frm-myForm" class="shell"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
13+
14+
<div><input type="text" name="name" form="frm-myForm" id="frm-myForm-name"></div>
15+
16+
17+
18+
<form action="" method="post" id="frm-myForm"><input type="hidden" name="_form_" value="myForm"><input type="hidden" name="id" value="42"></form>
19+
20+
<div>
21+
<input type="text" name="name" form="frm-myForm" id="frm-myForm-name">
22+
<form action="/other">
23+
<input name="foo">
24+
</form>
25+
<input type="submit" name="_submit" form="frm-myForm" value="Save">
26+
</div>
27+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php %A%
2+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 5:1 */;
3+
echo $this->global->forms->renderFormBegin([]) /* pos 5:1 */;
4+
echo $this->global->forms->renderFormEnd() /* pos 12:1 */;
5+
echo '
6+
<div class="layout">
7+
';
8+
echo ($ʟ_label = $this->global->forms->get('name')->getLabel()) /* pos 7:3 */;
9+
echo '
10+
';
11+
echo $this->global->forms->get('name')->getControl() /* pos 8:3 */;
12+
echo '
13+
';
14+
echo $this->global->forms->get('email')->getControl() /* pos 9:3 */;
15+
echo '
16+
';
17+
echo $this->global->forms->get('submit')->getControl() /* pos 10:3 */;
18+
echo '
19+
</div>
20+
';
21+
$this->global->forms->end();
22+
23+
echo '
24+
25+
26+
';
27+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 17:1 */;
28+
echo $this->global->forms->renderFormBegin(['class' => 'shell']) /* pos 17:1 */;
29+
echo $this->global->forms->renderFormEnd() /* pos 19:1 */;
30+
echo '
31+
<div>';
32+
echo $this->global->forms->get('name')->getControl() /* pos 18:7 */;
33+
echo '</div>
34+
';
35+
$this->global->forms->end();
36+
37+
echo '
38+
39+
40+
';
41+
$this->global->forms->begin($form = $this->global->uiControl['myForm'], detached: true) /* pos 24:1 */;
42+
echo $this->global->forms->renderFormBegin([]) /* pos 24:1 */;
43+
echo $this->global->forms->renderFormEnd() /* pos 33:1 */;
44+
echo '
45+
<div>
46+
';
47+
echo $this->global->forms->get('name')->getControl() /* pos 26:3 */;
48+
echo '
49+
<form action="/other">
50+
<input name="foo">
51+
</form>
52+
';
53+
echo $this->global->forms->get('submit')->getControl() /* pos 31:3 */;
54+
echo '
55+
</div>
56+
';
57+
$this->global->forms->end();
58+
%A%
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Bridges\FormsLatte\FormsExtension;
4+
use Nette\Forms\Form;
5+
use Tester\Assert;
6+
7+
require __DIR__ . '/../bootstrap.php';
8+
9+
10+
$main = new Form('main');
11+
$main->addText('name', 'Name:');
12+
$address = $main->addContainer('address');
13+
$address->addText('street', 'Street:');
14+
15+
$wrap = new Form('wrap');
16+
$wrap->addText('title', 'Title:');
17+
18+
$side = new Form('side');
19+
$side->addText('note', 'Note:');
20+
21+
$latte = new Latte\Engine;
22+
$latte->addExtension(new FormsExtension);
23+
$latte->addProvider('uiControl', ['main' => $main, 'wrap' => $wrap, 'side' => $side]);
24+
25+
Assert::matchFile(
26+
__DIR__ . '/expected/forms.detached-scope.php',
27+
$latte->compile(__DIR__ . '/templates/forms.detached-scope.latte'),
28+
);
29+
30+
Assert::matchFile(
31+
__DIR__ . '/expected/forms.detached-scope.html',
32+
$latte->renderToString(__DIR__ . '/templates/forms.detached-scope.latte'),
33+
);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Bridges\FormsLatte\FormsExtension;
4+
use Nette\Forms\Form;
5+
use Tester\Assert;
6+
7+
require __DIR__ . '/../bootstrap.php';
8+
9+
10+
$form = new Form('myForm');
11+
$form->addHidden('id', '42');
12+
$form->addText('name', 'Name:');
13+
$form->addText('email', 'Email:');
14+
$form->addSubmit('submit', 'Save');
15+
16+
$latte = new Latte\Engine;
17+
$latte->addExtension(new FormsExtension);
18+
$latte->addProvider('uiControl', ['myForm' => $form]);
19+
20+
Assert::matchFile(
21+
__DIR__ . '/expected/forms.detached.php',
22+
$latte->compile(__DIR__ . '/templates/forms.detached.latte'),
23+
);
24+
25+
Assert::matchFile(
26+
__DIR__ . '/expected/forms.detached.html',
27+
$latte->renderToString(__DIR__ . '/templates/forms.detached.latte'),
28+
);
29+
30+
31+
test('detached requires a Form instance, not a plain Container', function () {
32+
$container = new Nette\Forms\Container;
33+
$container->addText('name');
34+
$latte = new Latte\Engine;
35+
$latte->setLoader(new Latte\Loaders\StringLoader);
36+
$latte->addExtension(new FormsExtension);
37+
$latte->addProvider('uiControl', ['myCont' => $container]);
38+
39+
Assert::exception(
40+
fn() => $latte->renderToString("{form detached myCont}{/form}\n"),
41+
Nette\InvalidStateException::class,
42+
'Detached mode requires a Form instance.',
43+
);
44+
});
45+
46+
47+
test('detached form must have an id', function () {
48+
$form = new Nette\Forms\Form; // no name → no id
49+
$form->addText('name');
50+
$latte = new Latte\Engine;
51+
$latte->setLoader(new Latte\Loaders\StringLoader);
52+
$latte->addExtension(new FormsExtension);
53+
$latte->addProvider('uiControl', ['noName' => $form]);
54+
55+
Assert::exception(
56+
fn() => $latte->renderToString("{form detached noName}{/form}\n"),
57+
Nette\InvalidStateException::class,
58+
'Detached form must have an id%a%',
59+
);
60+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{* A: {form scope <container>} nested inside {form detached}.
2+
The sub-container's controls inherit the detached form= link. *}
3+
4+
{form detached main}
5+
{input name}
6+
{form scope address}
7+
{input street}
8+
{/form}
9+
{/form}
10+
11+
12+
{* B: {form detached} nested inside {form scope}.
13+
Detached resolves the top-level form (not relatively) and its controls link to it; the
14+
scope is not detached, so its own controls stay unlinked and it emits no <form>. *}
15+
16+
{form scope wrap}
17+
{input title}
18+
{form detached side}
19+
{input note}
20+
{/form}
21+
{/form}

0 commit comments

Comments
 (0)