Skip to content

Commit eb4fd0b

Browse files
authored
Add ConfigForm based on CompatForm (#5480)
`ConfigForm` uses a `section__key` element-name convention to map form fields directly to INI file entries. Populates elements from the config on assembly, writes changed values back on success and embeds the show configuration widget inline when saving fails.
1 parent 3b9d663 commit eb4fd0b

2 files changed

Lines changed: 325 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
namespace Icinga\Web\Form;
7+
8+
use Exception;
9+
use Icinga\Application\Config;
10+
use ipl\Html\Contract\FormElement;
11+
use ipl\Html\Contract\ValueCandidates;
12+
use ipl\Html\HtmlElement;
13+
use ipl\Html\ValidHtml;
14+
use ipl\Stdlib\Str;
15+
use ipl\Web\Compat\CompatForm;
16+
use ipl\Web\Compat\DisplayFormElement;
17+
use ipl\Web\Widget\CopyToClipboard;
18+
use LogicException;
19+
20+
/**
21+
* Form base-class providing standard functionality for configuration forms
22+
*
23+
* Element names follow a `section__key` convention: the part before the
24+
* {@see $sectionKeyDelimiter} (default: `__`) is the INI section name and the
25+
* part after is the configuration key within that section. Subclasses add
26+
* elements whose names match this pattern; `populateFromConfig` and `save`
27+
* use it to map form values to and from the INI file automatically.
28+
*/
29+
class ConfigForm extends CompatForm
30+
{
31+
/** @var string Name of the submit button element */
32+
protected const SUBMIT_BUTTON_NAME = 'store';
33+
34+
/**
35+
* Delimiter used to separate the section and key in the element name
36+
*
37+
* This is used to determine whether an element is a configuration key or a
38+
* section. The default delimiter is '__', this is chosen to allow for section
39+
* names that contain underscores.
40+
*
41+
* @var string
42+
*/
43+
protected string $sectionKeyDelimiter = '__';
44+
45+
/**
46+
* Create a new configuration form
47+
*
48+
* @param Config $config The ini file configuration object to use for the form
49+
*/
50+
public function __construct(
51+
protected Config $config,
52+
) {
53+
$this->on(static::ON_ELEMENT_REGISTERED, function (FormElement $element) {
54+
[$section, $key] = Str::symmetricSplit($element->getName(), $this->sectionKeyDelimiter, 2);
55+
if ($key === null || $element->hasValue()) {
56+
return;
57+
}
58+
59+
$configValue = $this->config->get($section, $key);
60+
if ($configValue === null) {
61+
return;
62+
}
63+
64+
if (! ($element instanceof ValueCandidates)) {
65+
$element->setValue($configValue);
66+
67+
return;
68+
}
69+
70+
$candidates = $element->getValueCandidates();
71+
if (empty($candidates)) {
72+
// Initial render: no prior submission, set value and seed candidates
73+
$element->setValue($configValue);
74+
$element->setValueCandidates([$configValue]);
75+
} else {
76+
// POST: candidates were set by registerElement; append config value so
77+
// PasswordElement's (count > 1) condition resolves DUMMYPASSWORD on re-render
78+
$element->setValueCandidates(array_merge($candidates, [$configValue]));
79+
}
80+
});
81+
}
82+
83+
/**
84+
* Ensure that all required form elements are present
85+
*
86+
* @return $this
87+
*/
88+
public function ensureAssembled(): static
89+
{
90+
if (! $this->hasBeenAssembled) {
91+
parent::ensureAssembled();
92+
$this->addRequiredElements();
93+
}
94+
95+
return $this;
96+
}
97+
98+
/**
99+
* Create a hint that explains a configuration save failure and how to fix it manually
100+
*
101+
* @param Exception $exception The exception thrown while saving
102+
* @param Config $config The configuration that failed to save
103+
*
104+
* @return ValidHtml
105+
*/
106+
public static function createConfigurationErrorHint(Exception $exception, Config $config): ValidHtml
107+
{
108+
$code = HtmlElement::create('code', null, (string) $config);
109+
CopyToClipboard::attachTo($code);
110+
111+
return HtmlElement::create('div', null, [
112+
HtmlElement::create('h4', null, t('Saving Configuration Failed!')),
113+
HtmlElement::create('p', null, [
114+
sprintf(
115+
t("The file %s couldn't be stored. (Error: '%s')"),
116+
$config->getConfigFile(),
117+
$exception->getMessage(),
118+
),
119+
HtmlElement::create('br'),
120+
t('This could have one or more of the following reasons:'),
121+
]),
122+
HtmlElement::create('ul', null, [
123+
HtmlElement::create('li', null, t("You don't have file-system permissions to write to the file")),
124+
HtmlElement::create('li', null, t('Something went wrong while writing the file')),
125+
HtmlElement::create('li', null, t(
126+
"There's an application error preventing you from persisting the configuration",
127+
)),
128+
]),
129+
HtmlElement::create('p', null, [
130+
t(
131+
'Details can be found in the application log. ' .
132+
"(If you don't have access to this log, call your administrator in this case)",
133+
),
134+
HtmlElement::create('br'),
135+
t('In case you can access the file by yourself, you can open it and insert the config manually:'),
136+
]),
137+
HtmlElement::create('p', null, HtmlElement::create('pre', null, $code)),
138+
]);
139+
}
140+
141+
/**
142+
* Persist the current configuration to disk
143+
*
144+
* If an error occurs, the form will be re-rendered with the error message
145+
* and the raw INI configuration.
146+
*
147+
* @return void
148+
*
149+
* @throws LogicException If an array value is encountered in the form
150+
*/
151+
protected function save(): void
152+
{
153+
foreach ($this->getValues() as $element => $value) {
154+
[$section, $key] = Str::symmetricSplit($element, $this->sectionKeyDelimiter, 2);
155+
if ($key === null) {
156+
continue;
157+
}
158+
159+
if (is_array($value)) {
160+
throw new LogicException(sprintf('Cannot save element "%s": array values are not supported', $element));
161+
}
162+
163+
$configSection = $this->config->getSection($section);
164+
if (Str::isEmpty($value)) {
165+
unset($configSection[$key]);
166+
} else {
167+
$configSection[$key] = $value;
168+
}
169+
170+
if ($configSection->isEmpty()) {
171+
$this->config->removeSection($section);
172+
} else {
173+
$this->config->setSection($section, $configSection);
174+
}
175+
}
176+
177+
$this->config->saveIni();
178+
}
179+
180+
/**
181+
* Handle the form submission
182+
*
183+
* If the form submission is successful, the configuration is saved to disk.
184+
* If an error occurs, the form is re-rendered with the error message and the
185+
* raw INI configuration. The original exception is re-thrown.
186+
*
187+
* @return void
188+
*/
189+
protected function onSuccess(): void
190+
{
191+
try {
192+
$this->save();
193+
} catch (Exception $e) {
194+
$content = $this->getContent();
195+
array_unshift($content, new DisplayFormElement(static::createConfigurationErrorHint($e, $this->config)));
196+
$this->setContent($content);
197+
198+
throw $e;
199+
}
200+
}
201+
202+
/**
203+
* Add the submit button to the form
204+
*
205+
* Called automatically during assembly. Subclasses may override this to add
206+
* additional required elements but must call the parent implementation.
207+
*
208+
* @return void
209+
*/
210+
protected function addRequiredElements(): void
211+
{
212+
$this->addElement('submit', static::SUBMIT_BUTTON_NAME, [
213+
'label' => $this->translate('Store'),
214+
'ignore' => true,
215+
]);
216+
}
217+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
namespace Tests\Icinga\Web\Form;
7+
8+
use Icinga\Application\Config;
9+
use Icinga\Data\ConfigObject;
10+
use Icinga\Test\BaseTestCase;
11+
use Icinga\Web\Form\ConfigForm;
12+
use ipl\Html\FormElement\PasswordElement;
13+
use LogicException;
14+
15+
class ConfigFormTest extends BaseTestCase
16+
{
17+
private function makeForm(array $configData = [])
18+
{
19+
return new class(Config::fromArray($configData)) extends ConfigForm {
20+
public function exposeSetSectionKeyDelimiter(string $delimiter): void
21+
{
22+
$this->sectionKeyDelimiter = $delimiter;
23+
}
24+
};
25+
}
26+
27+
public function testSubmitButtonIsAddedAfterAssembly(): void
28+
{
29+
$form = $this->makeForm();
30+
$form->ensureAssembled();
31+
$this->assertTrue($form->hasElement('store'));
32+
}
33+
34+
public function testSaveThrowsForArrayElementValue(): void
35+
{
36+
$this->expectException(LogicException::class);
37+
38+
$config = new class(new ConfigObject([])) extends Config {
39+
public function saveIni($filePath = null, $fileMode = 0660): void {}
40+
};
41+
42+
$form = new class($config) extends ConfigForm {
43+
protected function assemble(): void
44+
{
45+
$this->addElement('select', 'mysection__key', [
46+
'options' => ['a' => 'A', 'b' => 'B'],
47+
'multiple' => true,
48+
]);
49+
}
50+
51+
public function exposeSave(): void
52+
{
53+
$this->save();
54+
}
55+
};
56+
$form->ensureAssembled();
57+
$form->populate(['mysection__key' => ['a', 'b']]);
58+
$form->exposeSave();
59+
}
60+
61+
public function testUnchangedPasswordElementRetainsConfigValueOnSave(): void
62+
{
63+
$config = new class(new ConfigObject(['mysection' => ['password' => 'secret']])) extends Config {
64+
public function saveIni($filePath = null, $fileMode = 0660): void {}
65+
};
66+
67+
$form = new class($config) extends ConfigForm {
68+
protected function assemble(): void
69+
{
70+
$this->addElement('password', 'mysection__password');
71+
}
72+
73+
public function exposeSave(): void
74+
{
75+
$this->save();
76+
}
77+
};
78+
$form->populate(['mysection__password' => PasswordElement::DUMMYPASSWORD]);
79+
$form->ensureAssembled();
80+
$form->exposeSave();
81+
82+
$this->assertSame('secret', $config->get('mysection', 'password'));
83+
}
84+
85+
public function testEmptySectionIsRemovedOnSave(): void
86+
{
87+
$config = new class(new ConfigObject(['mysection' => ['key' => 'value']])) extends Config {
88+
public function saveIni($filePath = null, $fileMode = 0660): void {}
89+
};
90+
91+
$form = new class($config) extends ConfigForm {
92+
protected function assemble(): void
93+
{
94+
$this->addElement('text', 'mysection__key');
95+
}
96+
97+
public function exposeSave(): void
98+
{
99+
$this->save();
100+
}
101+
};
102+
$form->ensureAssembled();
103+
$form->populate(['mysection__key' => '']);
104+
$form->exposeSave();
105+
106+
$this->assertFalse($config->hasSection('mysection'));
107+
}
108+
}

0 commit comments

Comments
 (0)