-
Notifications
You must be signed in to change notification settings - Fork 282
Add ConfigForm based on CompatForm
#5480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| <?php | ||
|
|
||
| // SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com> | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| namespace Icinga\Web\Form; | ||
|
|
||
| use Exception; | ||
| use Icinga\Application\Config; | ||
| use ipl\Html\Contract\FormElement; | ||
| use ipl\Html\Contract\ValueCandidates; | ||
| use ipl\Html\HtmlElement; | ||
| use ipl\Html\ValidHtml; | ||
| use ipl\Stdlib\Str; | ||
| use ipl\Web\Compat\CompatForm; | ||
| use ipl\Web\Compat\DisplayFormElement; | ||
| use ipl\Web\Widget\CopyToClipboard; | ||
| use LogicException; | ||
|
|
||
| /** | ||
| * Form base-class providing standard functionality for configuration forms | ||
| * | ||
| * Element names follow a `section__key` convention: the part before the | ||
| * {@see $sectionKeyDelimiter} (default: `__`) is the INI section name and the | ||
| * part after is the configuration key within that section. Subclasses add | ||
| * elements whose names match this pattern; `populateFromConfig` and `save` | ||
| * use it to map form values to and from the INI file automatically. | ||
| */ | ||
| class ConfigForm extends CompatForm | ||
| { | ||
| /** @var string Name of the submit button element */ | ||
| protected const SUBMIT_BUTTON_NAME = 'store'; | ||
|
|
||
| /** | ||
| * Delimiter used to separate the section and key in the element name | ||
| * | ||
| * This is used to determine whether an element is a configuration key or a | ||
| * section. The default delimiter is '__', this is chosen to allow for section | ||
| * names that contain underscores. | ||
| * | ||
| * @var string | ||
| */ | ||
| protected string $sectionKeyDelimiter = '__'; | ||
|
|
||
| /** | ||
| * Create a new configuration form | ||
| * | ||
| * @param Config $config The ini file configuration object to use for the form | ||
| */ | ||
| public function __construct( | ||
| protected Config $config, | ||
|
jrauh01 marked this conversation as resolved.
|
||
| ) { | ||
| $this->on(static::ON_ELEMENT_REGISTERED, function (FormElement $element) { | ||
| [$section, $key] = Str::symmetricSplit($element->getName(), $this->sectionKeyDelimiter, 2); | ||
| if ($key === null || $element->hasValue()) { | ||
| return; | ||
| } | ||
|
|
||
| $configValue = $this->config->get($section, $key); | ||
| if ($configValue === null) { | ||
| return; | ||
| } | ||
|
|
||
| if (! ($element instanceof ValueCandidates)) { | ||
| $element->setValue($configValue); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $candidates = $element->getValueCandidates(); | ||
| if (empty($candidates)) { | ||
| // Initial render: no prior submission, set value and seed candidates | ||
| $element->setValue($configValue); | ||
| $element->setValueCandidates([$configValue]); | ||
| } else { | ||
| // POST: candidates were set by registerElement; append config value so | ||
| // PasswordElement's (count > 1) condition resolves DUMMYPASSWORD on re-render | ||
| $element->setValueCandidates(array_merge($candidates, [$configValue])); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Ensure that all required form elements are present | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function ensureAssembled(): static | ||
| { | ||
| if (! $this->hasBeenAssembled) { | ||
| parent::ensureAssembled(); | ||
| $this->addRequiredElements(); | ||
| } | ||
|
jrauh01 marked this conversation as resolved.
|
||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * Create a hint that explains a configuration save failure and how to fix it manually | ||
| * | ||
| * @param Exception $exception The exception thrown while saving | ||
| * @param Config $config The configuration that failed to save | ||
| * | ||
| * @return ValidHtml | ||
| */ | ||
| public static function createConfigurationErrorHint(Exception $exception, Config $config): ValidHtml | ||
| { | ||
| $code = HtmlElement::create('code', null, (string) $config); | ||
| CopyToClipboard::attachTo($code); | ||
|
|
||
| return HtmlElement::create('div', null, [ | ||
| HtmlElement::create('h4', null, t('Saving Configuration Failed!')), | ||
| HtmlElement::create('p', null, [ | ||
| sprintf( | ||
| t("The file %s couldn't be stored. (Error: '%s')"), | ||
| $config->getConfigFile(), | ||
| $exception->getMessage(), | ||
| ), | ||
| HtmlElement::create('br'), | ||
| t('This could have one or more of the following reasons:'), | ||
| ]), | ||
| HtmlElement::create('ul', null, [ | ||
| HtmlElement::create('li', null, t("You don't have file-system permissions to write to the file")), | ||
| HtmlElement::create('li', null, t('Something went wrong while writing the file')), | ||
| HtmlElement::create('li', null, t( | ||
| "There's an application error preventing you from persisting the configuration", | ||
| )), | ||
| ]), | ||
| HtmlElement::create('p', null, [ | ||
| t( | ||
| 'Details can be found in the application log. ' . | ||
| "(If you don't have access to this log, call your administrator in this case)", | ||
| ), | ||
| HtmlElement::create('br'), | ||
| t('In case you can access the file by yourself, you can open it and insert the config manually:'), | ||
| ]), | ||
| HtmlElement::create('p', null, HtmlElement::create('pre', null, $code)), | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * Persist the current configuration to disk | ||
| * | ||
| * If an error occurs, the form will be re-rendered with the error message | ||
| * and the raw INI configuration. | ||
| * | ||
| * @return void | ||
| * | ||
| * @throws LogicException If an array value is encountered in the form | ||
| */ | ||
|
jrauh01 marked this conversation as resolved.
|
||
| protected function save(): void | ||
| { | ||
| foreach ($this->getValues() as $element => $value) { | ||
| [$section, $key] = Str::symmetricSplit($element, $this->sectionKeyDelimiter, 2); | ||
| if ($key === null) { | ||
| continue; | ||
| } | ||
|
|
||
| if (is_array($value)) { | ||
| throw new LogicException(sprintf('Cannot save element "%s": array values are not supported', $element)); | ||
| } | ||
|
|
||
| $configSection = $this->config->getSection($section); | ||
| if (Str::isEmpty($value)) { | ||
| unset($configSection[$key]); | ||
| } else { | ||
| $configSection[$key] = $value; | ||
| } | ||
|
|
||
| if ($configSection->isEmpty()) { | ||
| $this->config->removeSection($section); | ||
| } else { | ||
| $this->config->setSection($section, $configSection); | ||
| } | ||
| } | ||
|
|
||
| $this->config->saveIni(); | ||
| } | ||
|
|
||
| /** | ||
| * Handle the form submission | ||
| * | ||
| * If the form submission is successful, the configuration is saved to disk. | ||
| * If an error occurs, the form is re-rendered with the error message and the | ||
| * raw INI configuration. The original exception is re-thrown. | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function onSuccess(): void | ||
| { | ||
| try { | ||
| $this->save(); | ||
| } catch (Exception $e) { | ||
| $content = $this->getContent(); | ||
| array_unshift($content, new DisplayFormElement(static::createConfigurationErrorHint($e, $this->config))); | ||
| $this->setContent($content); | ||
|
|
||
| throw $e; | ||
|
jrauh01 marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Add the submit button to the form | ||
| * | ||
| * Called automatically during assembly. Subclasses may override this to add | ||
| * additional required elements but must call the parent implementation. | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function addRequiredElements(): void | ||
|
jrauh01 marked this conversation as resolved.
|
||
| { | ||
| $this->addElement('submit', static::SUBMIT_BUTTON_NAME, [ | ||
| 'label' => $this->translate('Store'), | ||
| 'ignore' => true, | ||
| ]); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| <?php | ||
|
|
||
| // SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com> | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| namespace Tests\Icinga\Web\Form; | ||
|
|
||
| use Icinga\Application\Config; | ||
| use Icinga\Data\ConfigObject; | ||
| use Icinga\Test\BaseTestCase; | ||
| use Icinga\Web\Form\ConfigForm; | ||
| use ipl\Html\FormElement\PasswordElement; | ||
| use LogicException; | ||
|
|
||
| class ConfigFormTest extends BaseTestCase | ||
| { | ||
| private function makeForm(array $configData = []) | ||
| { | ||
| return new class(Config::fromArray($configData)) extends ConfigForm { | ||
| public function exposeSetSectionKeyDelimiter(string $delimiter): void | ||
| { | ||
| $this->sectionKeyDelimiter = $delimiter; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| public function testSubmitButtonIsAddedAfterAssembly(): void | ||
| { | ||
| $form = $this->makeForm(); | ||
| $form->ensureAssembled(); | ||
| $this->assertTrue($form->hasElement('store')); | ||
| } | ||
|
|
||
| public function testSaveThrowsForArrayElementValue(): void | ||
| { | ||
| $this->expectException(LogicException::class); | ||
|
|
||
| $config = new class(new ConfigObject([])) extends Config { | ||
| public function saveIni($filePath = null, $fileMode = 0660): void {} | ||
| }; | ||
|
|
||
| $form = new class($config) extends ConfigForm { | ||
| protected function assemble(): void | ||
| { | ||
| $this->addElement('select', 'mysection__key', [ | ||
| 'options' => ['a' => 'A', 'b' => 'B'], | ||
| 'multiple' => true, | ||
| ]); | ||
| } | ||
|
|
||
| public function exposeSave(): void | ||
| { | ||
| $this->save(); | ||
| } | ||
| }; | ||
| $form->ensureAssembled(); | ||
| $form->populate(['mysection__key' => ['a', 'b']]); | ||
| $form->exposeSave(); | ||
| } | ||
|
|
||
| public function testUnchangedPasswordElementRetainsConfigValueOnSave(): void | ||
| { | ||
| $config = new class(new ConfigObject(['mysection' => ['password' => 'secret']])) extends Config { | ||
| public function saveIni($filePath = null, $fileMode = 0660): void {} | ||
| }; | ||
|
|
||
| $form = new class($config) extends ConfigForm { | ||
| protected function assemble(): void | ||
| { | ||
| $this->addElement('password', 'mysection__password'); | ||
| } | ||
|
|
||
| public function exposeSave(): void | ||
| { | ||
| $this->save(); | ||
| } | ||
| }; | ||
| $form->populate(['mysection__password' => PasswordElement::DUMMYPASSWORD]); | ||
| $form->ensureAssembled(); | ||
| $form->exposeSave(); | ||
|
|
||
| $this->assertSame('secret', $config->get('mysection', 'password')); | ||
| } | ||
|
|
||
| public function testEmptySectionIsRemovedOnSave(): void | ||
| { | ||
| $config = new class(new ConfigObject(['mysection' => ['key' => 'value']])) extends Config { | ||
| public function saveIni($filePath = null, $fileMode = 0660): void {} | ||
| }; | ||
|
|
||
| $form = new class($config) extends ConfigForm { | ||
| protected function assemble(): void | ||
| { | ||
| $this->addElement('text', 'mysection__key'); | ||
| } | ||
|
|
||
| public function exposeSave(): void | ||
| { | ||
| $this->save(); | ||
| } | ||
| }; | ||
| $form->ensureAssembled(); | ||
| $form->populate(['mysection__key' => '']); | ||
| $form->exposeSave(); | ||
|
|
||
| $this->assertFalse($config->hasSection('mysection')); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test
yum remove icingaweb2-module-monitoring/usr/share/icingaweb2/modules/test5480icingacli mod en test5480Press entercat /etc/icingaweb2/modules/test5480/config.ini(ok)chmod 0440 /etc/icingaweb2/modules/test5480/config.iniSo far so good...
/usr/share/icingaweb2/modules/test5480library/Test5480/Form.phpapplication/controllers/IndexController.php