From 1ef149e0eb2b8e220a0f6d9a41341462f87aec50 Mon Sep 17 00:00:00 2001 From: raviks789 <33730024+raviks789@users.noreply.github.com> Date: Mon, 18 Dec 2023 11:23:25 +0100 Subject: [PATCH 1/6] Build a single form for the event rule configuration --- .../controllers/EventRuleController.php | 259 +++++--- .../controllers/EventRulesController.php | 125 ++-- application/forms/AddEscalationForm.php | 42 -- application/forms/AddFilterForm.php | 42 -- application/forms/BaseEscalationForm.php | 89 --- application/forms/EscalationConditionForm.php | 286 -------- application/forms/EscalationRecipientForm.php | 232 ------- .../EscalationCondition.php | 298 +++++++++ .../EscalationRecipient.php | 277 ++++++++ .../EventRuleConfigFilter.php | 95 +++ application/forms/EventRuleConfigForm.php | 621 ++++++++++++++++++ application/forms/EventRuleForm.php | 2 +- application/forms/RemoveEscalationForm.php | 70 -- application/forms/SaveEventRuleForm.php | 580 ---------------- .../Model/RuleEscalationRecipient.php | 1 - library/Notifications/Widget/Escalations.php | 64 -- .../Notifications/Widget/EventRuleConfig.php | 417 ------------ .../Widget/ItemList/Escalation.php | 94 +++ .../ItemList/EscalationConditionList.php | 47 ++ .../ItemList/EscalationConditionListItem.php | 105 +++ .../ItemList/EscalationRecipientList.php | 49 ++ .../ItemList/EscalationRecipientListItem.php | 100 +++ .../Widget/ItemList/Escalations.php | 32 + public/css/detail/event-rule-detail.less | 422 +----------- public/css/event-rule-config.less | 336 ++++++++++ 25 files changed, 2286 insertions(+), 2399 deletions(-) delete mode 100644 application/forms/AddEscalationForm.php delete mode 100644 application/forms/AddFilterForm.php delete mode 100644 application/forms/BaseEscalationForm.php delete mode 100644 application/forms/EscalationConditionForm.php delete mode 100644 application/forms/EscalationRecipientForm.php create mode 100644 application/forms/EventRuleConfigElements/EscalationCondition.php create mode 100644 application/forms/EventRuleConfigElements/EscalationRecipient.php create mode 100644 application/forms/EventRuleConfigElements/EventRuleConfigFilter.php create mode 100644 application/forms/EventRuleConfigForm.php delete mode 100644 application/forms/RemoveEscalationForm.php delete mode 100644 application/forms/SaveEventRuleForm.php delete mode 100644 library/Notifications/Widget/Escalations.php delete mode 100644 library/Notifications/Widget/EventRuleConfig.php create mode 100644 library/Notifications/Widget/ItemList/Escalation.php create mode 100644 library/Notifications/Widget/ItemList/EscalationConditionList.php create mode 100644 library/Notifications/Widget/ItemList/EscalationConditionListItem.php create mode 100644 library/Notifications/Widget/ItemList/EscalationRecipientList.php create mode 100644 library/Notifications/Widget/ItemList/EscalationRecipientListItem.php create mode 100644 library/Notifications/Widget/ItemList/Escalations.php create mode 100644 public/css/event-rule-config.less diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index c96c88bcd..43700c865 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -7,18 +7,21 @@ use Icinga\Module\Notifications\Common\Auth; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; +use Icinga\Module\Notifications\Forms\EventRuleConfigForm; use Icinga\Module\Notifications\Forms\EventRuleForm; -use Icinga\Module\Notifications\Forms\SaveEventRuleForm; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Web\Control\SearchBar\ExtraTagSuggestions; -use Icinga\Module\Notifications\Widget\EventRuleConfig; use Icinga\Web\Notification; use Icinga\Web\Session; +use ipl\Html\Attributes; use ipl\Html\Form; +use ipl\Html\FormElement\SubmitButtonElement; use ipl\Html\Html; +use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; use ipl\Web\Url; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; @@ -33,66 +36,105 @@ class EventRuleController extends CompatController public function init() { $this->sessionNamespace = Session::getSession()->getNamespace('notifications'); + $this->assertPermission('notifications/config/event-rule'); } public function indexAction(): void { - $this->assertPermission('notifications/config/event-rules'); + $this->sessionNamespace->delete('-1'); $this->addTitleTab(t('Event Rule')); - $this->controls->addAttributes(['class' => 'event-rule-detail']); $ruleId = $this->params->getRequired('id'); + $configValues = $this->sessionNamespace->get($ruleId); + $this->controls->addAttributes(['class' => 'event-rule-detail']); - $cache = $this->sessionNamespace->get($ruleId); - - if ($cache) { - $this->addContent(Html::tag('div', ['class' => 'cache-notice'], t('There are unsaved changes.'))); - $eventRuleConfig = new EventRuleConfig( - Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]), - $cache - ); - } else { - $eventRuleConfig = new EventRuleConfig( - Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]), - $this->fromDb($ruleId) - ); + $disableSave = false; + if ($configValues === null) { + $configValues = $this->fromDb((int) $ruleId); + $disableSave = true; } - $saveForm = (new SaveEventRuleForm()) - ->setShowRemoveButton() - ->setShowDismissChangesButton($cache !== null) - ->setSubmitButtonDisabled($cache === null) - ->setSubmitLabel($this->translate('Save Changes')) - ->on(SaveEventRuleForm::ON_SUCCESS, function ($form) use ($ruleId, $eventRuleConfig) { - if ($form->getPressedSubmitElement()->getName() === 'discard_changes') { - $this->sessionNamespace->delete($ruleId); - Notification::success($this->translate('Successfully discarded the pending changes.')); - $this->redirectNow(Links::eventRule($ruleId)); - } + $eventRuleConfig = new EventRuleConfigForm( + $configValues, + Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]) + ); - if (! $eventRuleConfig->isValid()) { - $eventRuleConfig->addAttributes(['class' => 'invalid']); - return; + $eventRuleConfig + ->populate($configValues) + ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { + $form->addOrUpdateRule((int) $ruleId, $configValues); + $this->sessionNamespace->delete($ruleId); + Notification::success((sprintf(t('Successfully saved event rule %s'), $configValues['name']))); + $this->redirectNow(Links::eventRule((int) $ruleId)); + }) + ->on(EventRuleConfigForm::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { + if ($form->hasBeenRemoved()) { + $form->removeRule((int) $ruleId); + $this->sessionNamespace->delete($ruleId); + Notification::success(sprintf(t('Successfully deleted event rule %s'), $configValues['name'])); + $this->redirectNow(Links::eventRules()); + } elseif ($form->hasBeenDiscarded()) { + $this->sessionNamespace->delete($ruleId); + Notification::success( + sprintf( + t('Successfully discarded changes to event rule %s'), + $configValues['name'] + ) + ); + $this->redirectNow(Links::eventRule((int) $ruleId)); } + }) + ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { + $formValues = $form->getValues(); + $configValues = array_merge($configValues, $formValues); + $configValues['rule_escalation'] = $formValues['rule_escalation']; + $this->sessionNamespace->set($ruleId, $configValues); + }) + ->handleRequest($this->getServerRequest()); - $form->editRule($ruleId, $this->sessionNamespace->get($ruleId)); - $this->sessionNamespace->delete($ruleId); + $cache = $this->sessionNamespace->get($ruleId); + $discardChangesButton = null; + if ($cache !== null) { + $this->addContent(Html::tag('div', ['class' => 'cache-notice'], t('There are unsaved changes.'))); + $discardChangesButton = new SubmitButtonElement( + 'discard_changes', + [ + 'label' => t('Discard Changes'), + 'form' => 'event-rule-config-form', + 'class' => 'btn-discard-changes', + 'formnovalidate' => true, + ] + ); - Notification::success($this->translate('Successfully updated rule.')); - $this->sendExtraUpdates(['#col1']); - $this->redirectNow(Links::eventRule($ruleId)); - })->on(SaveEventRuleForm::ON_REMOVE, function ($form) use ($ruleId) { - $form->removeRule($ruleId); - $this->sessionNamespace->delete($ruleId); + $disableSave = false; + } - Notification::success($this->translate('Successfully removed rule.')); - $this->redirectNow('__CLOSE__'); - })->handleRequest($this->getServerRequest()); + $buttonsWrapper = new HtmlElement('div', Attributes::create(['class' => ['icinga-controls', 'save-config']])); + $eventRuleConfigSubmitButton = new SubmitButtonElement( + 'save', + [ + 'label' => t('Save'), + 'form' => 'event-rule-config-form', + 'disabled' => $disableSave + ] + ); + + $deleteButton = new SubmitButtonElement( + 'delete', + [ + 'label' => t('Delete'), + 'form' => 'event-rule-config-form', + 'class' => 'btn-remove', + 'formnovalidate' => true + ] + ); + + $buttonsWrapper->add([$eventRuleConfigSubmitButton, $discardChangesButton, $deleteButton]); $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $eventRuleConfig->getConfig()['name'] ?? ''), + Html::tag('h2', $configValues['name']), (new Link( new Icon('edit'), Url::fromPath('notifications/event-rule/edit', [ @@ -102,29 +144,8 @@ public function indexAction(): void ))->openInModal() ]); - $eventRuleFormAndSave = Html::tag('div', ['class' => 'event-rule-and-save-forms']); - $eventRuleFormAndSave->add([ - $eventRuleForm, - $saveForm - ]); - - $eventRuleConfig - ->on(EventRuleConfig::ON_CHANGE, function ($eventRuleConfig) use ($ruleId, $saveForm) { - $this->sessionNamespace->set($ruleId, $eventRuleConfig->getConfig()); - $saveForm->setSubmitButtonDisabled(false); - $this->redirectNow(Links::eventRule($ruleId)); - }); - - foreach ($eventRuleConfig->getForms() as $form) { - $form->handleRequest($this->getServerRequest()); - - if (! $form->hasBeenSent()) { - // Force validation of populated values in case we display an unsaved rule - $form->validatePartial(); - } - } - - $this->addControl($eventRuleFormAndSave); + $this->addControl($eventRuleForm); + $this->addControl($buttonsWrapper); $this->addContent($eventRuleConfig); } @@ -132,6 +153,7 @@ public function indexAction(): void * Create config from db * * @param int $ruleId + * * @return array */ public function fromDb(int $ruleId): array @@ -153,7 +175,9 @@ public function fromDb(int $ruleId): array foreach ($ruleEscalations as $re) { foreach ($re as $k => $v) { - $config[$re->getTableName()][$re->position][$k] = $v; + if (in_array($k, ['id', 'condition'])) { + $config[$re->getTableName()][$re->position][$k] = (string) $v; + } } $escalationRecipients = $re @@ -161,12 +185,20 @@ public function fromDb(int $ruleId): array ->withoutColumns(['changed_at', 'deleted']); foreach ($escalationRecipients as $recipient) { - $config[$re->getTableName()][$re->position]['recipient'][] = iterator_to_array($recipient); + $requiredValues = []; + + foreach ($recipient as $k => $v) { + if ($v !== null && in_array($k, ['contact_id', 'contactgroup_id', 'schedule_id'])) { + $requiredValues[$k] = (string) $v; + } elseif (in_array($k, ['id', 'channel_id'])) { + $requiredValues[$k] = $v ? (string) $v : null; + } + } + + $config[$re->getTableName()][$re->position]['recipients'][] = $requiredValues; } } - $config['showSearchbar'] = ! empty($config['object_filter']); - return $config; } @@ -182,7 +214,6 @@ public function completeAction(): void $this->getDocument()->add($suggestions); } - /** * searchEditorAction for Object Extra Tags * @@ -192,16 +223,30 @@ public function completeAction(): void */ public function searchEditorAction(): void { + /** @var string $ruleId */ $ruleId = $this->params->shiftRequired('id'); - $eventRule = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); + $eventRule = $this->sessionNamespace->get($ruleId); + + if ($eventRule === null) { + $eventRule = $this->fromDb((int) $ruleId); + } - $editor = EventRuleConfig::createSearchEditor() - ->setQueryString($eventRule['object_filter'] ?? ''); + $editor = new SearchEditor(); - $editor->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) use ($ruleId, $eventRule) { - $eventRule['object_filter'] = EventRuleConfig::createFilterString($form->getFilter()); + $objectFilter = $eventRule['object_filter'] ?? ''; + $editor->setQueryString($objectFilter) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->setSuggestionUrl( + Url::fromPath('notifications/event-rule/complete', [ + 'id' => $ruleId, + '_disableLayout' => true, + 'showCompact' => true + ]) + ); + $editor->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) use ($ruleId, $eventRule) { + $eventRule['object_filter'] = self::createFilterString($form->getFilter()); $this->sessionNamespace->set($ruleId, $eventRule); $this->getResponse() ->setHeader('X-Icinga-Container', '_self') @@ -215,51 +260,59 @@ public function searchEditorAction(): void $editor->handleRequest($this->getServerRequest()); - $this->getDocument()->add($editor); + $this->getDocument()->addHtml($editor); $this->setTitle($this->translate('Adjust Filter')); } + /** + * Create filter string from the given filter rule + * + * @param Filter\Rule $filters + * + * @return ?string + */ + public static function createFilterString(Filter\Rule $filters): ?string + { + if ($filters instanceof Filter\Chain) { + foreach ($filters as $filter) { + /** @var Filter\Condition $filter */ + $filter->setValue(true); + } + } elseif ($filters instanceof Filter\Condition && empty($filters->getValue())) { + $filters->setValue(true); + } + + $filterStr = QueryString::render($filters); + + return $filterStr !== '' ? rawurldecode($filterStr) : null; + } + public function editAction(): void { /** @var string $ruleId */ $ruleId = $this->params->getRequired('id'); - /** @var ?array $cache */ - $cache = $this->sessionNamespace->get($ruleId); - - if ($this->params->has('clearCache')) { - $this->sessionNamespace->delete($ruleId); - $cache = []; - } - - if (isset($cache) || $ruleId === '-1') { - $config = $cache ?? []; - } else { - $config = $this->fromDb((int) $ruleId); + $config = $this->sessionNamespace->get($ruleId); + if ($config === null) { + if ($ruleId === '-1') { + $config = ['id' => $ruleId]; + } else { + $config = $this->fromDb((int) $ruleId); + } } $eventRuleForm = (new EventRuleForm()) ->populate($config) ->setAction(Url::fromRequest()->getAbsoluteUrl()) - ->on(Form::ON_SUCCESS, function ($form) use ($ruleId, $cache, $config) { + ->on(Form::ON_SUCCESS, function ($form) use ($ruleId, $config) { $config['name'] = $form->getValue('name'); - - if ($cache || $ruleId === '-1') { - $this->sessionNamespace->set($ruleId, $config); - } else { - (new SaveEventRuleForm())->editRule((int) $ruleId, $config); - } - if ($ruleId === '-1') { - $redirectUrl = Url::fromPath('notifications/event-rules/add', [ - 'use_cache' => true - ]); + $redirectUrl = Url::fromPath('notifications/event-rules/add', ['id' => '-1']); } else { - $redirectUrl = Url::fromPath('notifications/event-rule', [ - 'id' => $ruleId - ]); + $redirectUrl = Url::fromPath('notifications/event-rule', ['id' => $ruleId]); $this->sendExtraUpdates(['#col1']); } + $this->sessionNamespace->set($ruleId, $config); $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); $this->redirectNow($redirectUrl); })->handleRequest($this->getServerRequest()); diff --git a/application/controllers/EventRulesController.php b/application/controllers/EventRulesController.php index d7c294b2d..ea91bb16c 100644 --- a/application/controllers/EventRulesController.php +++ b/application/controllers/EventRulesController.php @@ -6,20 +6,22 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; -use Icinga\Module\Notifications\Forms\SaveEventRuleForm; +use Icinga\Module\Notifications\Forms\EventRuleConfigForm; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\View\EventRuleRenderer; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; -use Icinga\Module\Notifications\Widget\EventRuleConfig; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; use Icinga\Web\Notification; use Icinga\Web\Session; +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\SubmitButtonElement; use ipl\Html\Html; +use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; use ipl\Web\Control\LimitControl; -use ipl\Web\Control\SearchEditor; use ipl\Web\Control\SortControl; use ipl\Web\Filter\QueryString; use ipl\Web\Layout\DetailedItemLayout; @@ -47,6 +49,7 @@ public function init() public function indexAction(): void { $eventRules = Rule::on(Database::get()); + $this->sessionNamespace->delete('-1'); $limitControl = $this->createLimitControl(); $paginationControl = $this->createPaginationControl($eventRules); @@ -87,7 +90,7 @@ public function indexAction(): void $this->addContent( (new ButtonLink( t('New Event Rule'), - Url::fromPath('notifications/event-rule/edit', ['id' => -1, 'clearCache' => true]), + Url::fromPath('notifications/event-rule/edit', ['id' => -1]), 'plus' ))->openInModal() ->addAttributes(['class' => 'add-new-component']) @@ -109,22 +112,49 @@ public function indexAction(): void public function addAction(): void { $this->addTitleTab(t('Add Event Rule')); - $this->getTabs()->setRefreshUrl(Url::fromPath('notifications/event-rules/add')); + $this->getTabs()->setRefreshUrl(Url::fromPath('notifications/event-rules/add', ['id' => '-1'])); $this->controls->addAttributes(['class' => 'event-rule-detail']); + $ruleId = $this->params->get('id'); + $config = $this->sessionNamespace->get($ruleId); + $config['object_filter'] = $config['object_filter'] ?? null; - if ($this->params->has('use_cache') || $this->getServerRequest()->getMethod() !== 'GET') { - $cache = $this->sessionNamespace->get(-1, []); - } else { - $this->sessionNamespace->delete(-1); - - $cache = []; - } + $eventRuleConfigSubmitButton = (new SubmitButtonElement( + 'save', + [ + 'label' => t('Add Event Rule'), + 'form' => 'event-rule-config-form' + ] + ))->setWrapper(new HtmlElement('div', Attributes::create(['class' => ['icinga-controls', 'save-config']]))); + + $eventRuleConfig = new EventRuleConfigForm( + $config, + Url::fromPath( + 'notifications/event-rules/search-editor', + ['id' => $ruleId] + ) + ); - $eventRuleConfig = new EventRuleConfig(Url::fromPath('notifications/event-rules/add-search-editor'), $cache); + $eventRuleConfig + ->populate($config) + ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($config) { + $ruleId = (int) $config['id']; + $ruleName = $config['name']; + $insertId = $form->addOrUpdateRule($ruleId, $config); + $this->sessionNamespace->delete($ruleId); + Notification::success(sprintf(t('Successfully add event rule %s'), $ruleName)); + $this->redirectNow(Links::eventRule($insertId)); + }) + ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($config) { + $formValues = $form->getValues(); + $config = array_merge($config, $formValues); + $config['rule_escalation'] = $formValues['rule_escalation']; + $this->sessionNamespace->set('-1', $config); + }) + ->handleRequest($this->getServerRequest()); $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $eventRuleConfig->getConfig()['name'] ?? ''), + Html::tag('h2', $config['name'] ?? ''), (new Link( new Icon('edit'), Url::fromPath('notifications/event-rule/edit', [ @@ -134,42 +164,8 @@ public function addAction(): void ))->openInModal() ]); - $saveForm = (new SaveEventRuleForm()) - ->on(SaveEventRuleForm::ON_SUCCESS, function ($saveForm) use ($eventRuleConfig) { - if (! $eventRuleConfig->isValid()) { - $eventRuleConfig->addAttributes(['class' => 'invalid']); - return; - } - - $id = $saveForm->addRule($this->sessionNamespace->get(-1)); - - Notification::success($this->translate('Successfully added rule.')); - $this->sendExtraUpdates(['#col1']); - $this->redirectNow(Links::eventRule($id)); - })->handleRequest($this->getServerRequest()); - - $eventRuleConfig->on(EventRuleConfig::ON_CHANGE, function ($eventRuleConfig) { - $this->sessionNamespace->set(-1, $eventRuleConfig->getConfig()); - - $this->redirectNow(Url::fromPath('notifications/event-rules/add', ['use_cache' => true])); - }); - - foreach ($eventRuleConfig->getForms() as $f) { - $f->handleRequest($this->getServerRequest()); - - if (! $f->hasBeenSent()) { - // Force validation of populated values in case we display an unsaved rule - $f->validatePartial(); - } - } - - $eventRuleFormAndSave = Html::tag('div', ['class' => 'event-rule-and-save-forms']); - $eventRuleFormAndSave->add([ - $eventRuleForm, - $saveForm - ]); - - $this->addControl($eventRuleFormAndSave); + $this->addControl($eventRuleForm); + $this->addControl($eventRuleConfigSubmitButton); $this->addContent($eventRuleConfig); } @@ -195,35 +191,6 @@ public function searchEditorAction(): void $this->setTitle($this->translate('Adjust Filter')); } - public function addSearchEditorAction(): void - { - $cache = $this->sessionNamespace->get(-1); - - $editor = EventRuleConfig::createSearchEditor() - ->setQueryString($cache['object_filter'] ?? ''); - - $editor->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) { - $cache = $this->sessionNamespace->get(-1); - $cache['object_filter'] = EventRuleConfig::createFilterString($form->getFilter()); - - $this->sessionNamespace->set(-1, $cache); - - $this->getResponse() - ->setHeader('X-Icinga-Container', '_self') - ->redirectAndExit( - Url::fromPath( - 'notifications/event-rules/add', - ['use_cache' => true] - ) - ); - }); - - $editor->handleRequest($this->getServerRequest()); - - $this->getDocument()->addHtml($editor); - $this->setTitle($this->translate('Adjust Filter')); - } - /** * Get the filter created from query string parameters * diff --git a/application/forms/AddEscalationForm.php b/application/forms/AddEscalationForm.php deleted file mode 100644 index d6d90dd5a..000000000 --- a/application/forms/AddEscalationForm.php +++ /dev/null @@ -1,42 +0,0 @@ - ['add-escalation-form', 'icinga-form', 'icinga-controls'], - 'name' => 'add-escalation-form' - ]; - - - protected function assemble() - { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - $this->addElement($this->createUidElement()); - - - $this->addElement( - 'submitButton', - 'add', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'title' => $this->translate('Add a new escalation') - ] - ); - } -} diff --git a/application/forms/AddFilterForm.php b/application/forms/AddFilterForm.php deleted file mode 100644 index eb0d7a9d9..000000000 --- a/application/forms/AddFilterForm.php +++ /dev/null @@ -1,42 +0,0 @@ - ['add-filter-form', 'icinga-form', 'icinga-controls'], - 'name' => 'add-filter-form' - ]; - - - protected function assemble() - { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - $this->addElement($this->createUidElement()); - - - $this->addElement( - 'submitButton', - 'add', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'title' => $this->translate('Add filter') - ] - ); - } -} diff --git a/application/forms/BaseEscalationForm.php b/application/forms/BaseEscalationForm.php deleted file mode 100644 index 1fda978b0..000000000 --- a/application/forms/BaseEscalationForm.php +++ /dev/null @@ -1,89 +0,0 @@ - ['escalation-form', 'icinga-form', 'icinga-controls']]; - - /** @var int The count of existing conditions/recipients */ - protected $count; - - /** @var bool Whether the `add` button is pressed */ - protected $isAddPressed; - - /** @var ValidHtml[] */ - protected $options; - - /** @var ?int The counter of removed option */ - protected $removedOptionNumber; - - public function __construct(int $count) - { - $this->count = $count; - } - - public function hasBeenSubmitted() - { - return false; - } - - abstract protected function assembleElements(): void; - - protected function createAddButton(): FormElement - { - $addButton = $this->createElement( - 'submitButton', - 'add', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'title' => $this->translate('Add more'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($addButton); - - return $addButton; - } - - protected function assemble() - { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - $this->addElement($this->createUidElement()); - - $addButton = $this->createAddButton(); - - $button = $this->getPressedSubmitElement(); - if ($button && $button->getName() === 'add') { - $this->isAddPressed = true; - } - - if ($this->count || $this->isAddPressed) { - $this->assembleElements(); - } - - $this->add($addButton); - } - - public function isAddButtonPressed(): ?bool - { - return $this->isAddPressed; - } -} diff --git a/application/forms/EscalationConditionForm.php b/application/forms/EscalationConditionForm.php deleted file mode 100644 index f49812503..000000000 --- a/application/forms/EscalationConditionForm.php +++ /dev/null @@ -1,286 +0,0 @@ -addAttributes(['class' => 'escalation-condition-form']); - - parent::__construct($count ?? 0); - } - - protected function assembleElements(): void - { - $end = $this->count; - if ($this->isAddPressed) { - $end++; - } - - foreach (range(1, $end) as $count) { - $col = $this->createElement( - 'select', - 'column' . $count, - [ - 'class' => ['autosubmit', 'left-operand'], - 'options' => [ - '' => sprintf(' - %s - ', $this->translate('Please choose')), - 'incident_severity' => $this->translate('Incident Severity'), - 'incident_age' => $this->translate('Incident Age') - ], - 'disabledOptions' => [''], - 'required' => true - ] - ); - - $operators = ['=', '>', '>=', '<', '<=', '!=']; - $op = $this->createElement( - 'select', - 'operator' . $count, - [ - 'class' => ['class' => 'operator-input', 'autosubmit'], - 'options' => array_combine($operators, $operators), - 'required' => true - ] - ); - - switch ($this->getPopulatedValue('column' . $count)) { - case 'incident_severity': - $val = $this->createElement( - 'select', - 'value' . $count, - [ - 'class' => ['autosubmit', 'right-operand'], - 'options' => [ - 'ok' => $this->translate('Ok', 'notification.severity'), - 'debug' => $this->translate('Debug', 'notification.severity'), - 'info' => $this->translate('Information', 'notification.severity'), - 'notice' => $this->translate('Notice', 'notification.severity'), - 'warning' => $this->translate('Warning', 'notification.severity'), - 'err' => $this->translate('Error', 'notification.severity'), - 'crit' => $this->translate('Critical', 'notification.severity'), - 'alert' => $this->translate('Alert', 'notification.severity'), - 'emerg' => $this->translate('Emergency', 'notification.severity') - ] - ] - ); - - if ( - $this->getPopulatedValue('type' . $count) !== 'incident_severity' - && $this->getPopulatedValue('type' . $count) !== null - ) { - $this->clearPopulatedValue('type' . $count); - $this->clearPopulatedValue('value' . $count); - } - - $this->addElement('hidden', 'type' . $count, [ - 'ignore' => true, - 'value' => 'incident_severity' - ]); - - break; - case 'incident_age': - $val = $this->createElement( - 'text', - 'value' . $count, - [ - 'required' => true, - 'class' => ['autosubmit', 'right-operand'], - 'validators' => [new CallbackValidator(function ($value, $validator) { - if (! preg_match('~^\d+(?:\.?\d*)?[hms]{1}$~', $value)) { - $validator->addMessage($this->translate( - 'Only numbers with optional fractions (separated by a dot)' - . ' and one of these suffixes are allowed: h, m, s' - )); - - return false; - } - - return true; - })] - ] - ); - - if ( - $this->getPopulatedValue('type' . $count) !== 'incident_age' - && $this->getPopulatedValue('type' . $count) !== null - ) { - $this->clearPopulatedValue('type' . $count); - $this->clearPopulatedValue('value' . $count); - } - - $this->addElement('hidden', 'type' . $count, [ - 'ignore' => true, - 'value' => 'incident_age' - ]); - - break; - default: - $val = $this->createElement('text', 'value' . $count, [ - 'class' => 'right-operand', - 'placeholder' => $this->translate('Please make a decision'), - 'disabled' => true - ]); - } - - $this->registerElement($col); - $this->registerElement($op); - $this->registerElement($val); - - (new EventRuleDecorator())->decorate($val); - - $this->options[$count] = Html::tag( - 'li', - ['class' => 'option'], - [$col, $op, $val, $this->createRemoveButton($count)] - ); - } - - $this->handleRemove(); - - $this->add(Html::tag('ul', ['class' => 'options'], $this->options)); - } - - public function getValues() - { - $filter = Filter::any(); - - if ($this->count > 0) { // if count is 0, loop runs in reverse direction - foreach (range(1, $this->count) as $count) { - if ($this->removedOptionNumber === $count) { - continue; // removed option - } - - $chosenType = $this->getValue('column' . $count, 'placeholder'); - - $filterStr = $chosenType - . $this->getValue('operator' . $count) - . ($this->getValue('value' . $count) ?? ($chosenType === 'incident_severity' ? 'ok' : '')); - - $filter->add(QueryString::parse($filterStr)); - } - } - - if ($this->isAddPressed) { - $filter->add(QueryString::parse('placeholder=')); - } - - return (new FilterRenderer($filter)) - ->render(); - } - - public function populate($values) - { - foreach ($values as $key => $condition) { - if (! is_int($key)) { - // csrf token and uid - continue; - } - - $count = $key + 1; - if (empty($condition)) { // when other conditions are removed and only 1 pending with no values - $values['column' . $count] = null; - $values['operator' . $count] = null; - $values['value' . $count] = null; - - continue; - } - - $filter = QueryString::parse($condition); - - $values['column' . $count] = $filter->getColumn() === 'placeholder' ? null : $filter->getColumn(); - $values['operator' . $count] = QueryString::getRuleSymbol($filter); - $values['value' . $count] = $filter->getValue(); - } - - return parent::populate($values); - } - - protected function createRemoveButton(int $count): ?FormElement - { - if ($this->deleteRemoveButton && $this->count === 1 && ! $this->isAddPressed) { - return null; - } - - $removeButton = $this->createElement( - 'submitButton', - 'remove_' . $count, - [ - 'class' => ['remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus'), - 'title' => $this->translate('Remove'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($removeButton); - - return $removeButton; - } - - protected function handleRemove(): void - { - $button = $this->getPressedSubmitElement(); - - if ($button && $button->getName() !== 'add') { - [$name, $toRemove] = explode('_', $button->getName(), 2); - $toRemove = (int) $toRemove; - $this->removedOptionNumber = $toRemove; - $optionCount = count($this->options); - - for ($i = $toRemove; $i < $optionCount; $i++) { - $nextCount = $i + 1; - $this->getElement('column' . $nextCount)->setName('column' . $i); - $this->getElement('operator' . $nextCount)->setName('operator' . $i); - $this->getElement('value' . $nextCount)->setName('value' . $i); - - $this->getElement('remove_' . $nextCount)->setName('remove_' . $i); - } - - unset($this->options[$toRemove]); - - if ($this->deleteRemoveButton && count($this->options) === 1) { - $key = key($this->options); - $this->options[$key]->remove($this->getElement('remove_' . $key)); - } - } - - if (empty($this->options)) { - $this->addAttributes(['class' => 'count-zero-escalation-condition-form']); - } else { - $this->getAttributes() - ->remove('class', 'count-zero-escalation-condition-form'); - } - } - - /** - * Whether to delete the remove button - * - * @param bool $delete - * - * @return $this - */ - public function deleteRemoveButton(bool $delete = true): self - { - $this->deleteRemoveButton = $delete; - - return $this; - } -} diff --git a/application/forms/EscalationRecipientForm.php b/application/forms/EscalationRecipientForm.php deleted file mode 100644 index 28b7a6c09..000000000 --- a/application/forms/EscalationRecipientForm.php +++ /dev/null @@ -1,232 +0,0 @@ -addAttributes(['class' => 'escalation-recipient-form']); - - parent::__construct($count ?? 1); - } - - protected function fetchOptions(): array - { - $options = []; - foreach (Contact::on(Database::get()) as $contact) { - $options['Contacts']['contact_' . $contact->id] = $contact->full_name; - } - - foreach (Contactgroup::on(Database::get()) as $contactgroup) { - $options['Contact Groups']['contactgroup_' . $contactgroup->id] = $contactgroup->name; - } - - foreach (Schedule::on(Database::get()) as $schedule) { - $options['Schedules']['schedule_' . $schedule->id] = $schedule->name; - } - - return $options; - } - - protected function assembleElements(): void - { - $end = $this->count; - if ($this->isAddPressed) { - $end++; - } - - foreach (range(1, $end) as $count) { - $escalationRecipientId = $this->createElement( - 'hidden', - 'id' . $count - ); - - $this->registerElement($escalationRecipientId); - - $col = $this->createElement( - 'select', - 'column' . $count, - [ - 'class' => ['autosubmit', 'left-operand'], - 'options' => [ - '' => sprintf(' - %s - ', $this->translate('Please choose')) - ] + $this->fetchOptions(), - 'disabledOptions' => [''], - 'required' => true - ] - ); - - $this->registerElement($col); - - $options = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $options += Channel::fetchChannelNames(Database::get()); - - $val = $this->createElement( - 'select', - 'value' . $count, - [ - 'class' => ['autosubmit', 'right-operand'], - 'options' => $options, - 'disabledOptions' => [''] - ] - ); - - if ($this->getValue('column' . $count) !== null) { - $recipient = explode('_', $this->getValue('column' . $count)); - if ($recipient[0] === 'contact') { - $options[''] = $this->translate('Default User Channel'); - - $val->setOptions($options); - - $val->setDisabledOptions([]); - - if ($this->getPopulatedValue('value' . $count, '') === '') { - $val->addAttributes(['class' => 'default-channel']); - } - } - } else { - $val = $this->createElement('text', 'value' . $count, [ - 'class' => 'right-operand', - 'placeholder' => $this->translate('Please make a decision'), - 'disabled' => true - ]); - } - - $this->registerElement($val); - - $this->options[$count] = Html::tag( - 'li', - ['class' => 'option'], - [$col, $val, $this->createRemoveButton($count)] - ); - } - - $this->handleRemove(); - - $this->add(Html::tag('ul', ['class' => 'options'], $this->options)); - } - - public function getValues() - { - $end = $this->count; - if ($this->isAddPressed) { - $end++; - } - - $values = []; - foreach (range(1, $end) as $count) { - if ($this->removedOptionNumber === $count) { - continue; // removed option - } - - $value = []; - $value['channel_id'] = $this->getValue('value' . $count); - $value['id'] = $this->getValue('id' . $count); - - $columnName = $this->getValue('column' . $count); - - if ($columnName === null) { - $values[] = $value; - continue; - } - - [$columnName, $id] = explode('_', $columnName, 2); - - $value[$columnName . '_id'] = $id; - - $values[] = $value; - } - - return $values; - } - - public function populate($values) - { - /** @var int $key */ - foreach ($values as $key => $condition) { - if (is_array($condition)) { - $count = 0; - foreach ($condition as $elementName => $elementValue) { - if ($elementValue === null) { - continue; - } - - $count = $key + 1; - $selectedOption = str_replace('id', $elementValue, $elementName, $replaced); - if ($replaced && $elementName !== 'channel_id') { - $values['column' . $count] = $selectedOption; - } elseif ($elementName === 'channel_id') { - $values['value' . $count] = $elementValue; - } - } - - if (isset($condition['id'])) { - $values['id' . $count] = $condition['id']; - } - } - } - - return parent::populate($values); - } - - protected function createRemoveButton(int $count): ?FormElement - { - if ($this->count === 1 && ! $this->isAddPressed) { - return null; - } - - $removeButton = $this->createElement( - 'submitButton', - 'remove_' . $count, - [ - 'class' => ['remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus'), - 'title' => $this->translate('Remove'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($removeButton); - - return $removeButton; - } - - protected function handleRemove(): void - { - $button = $this->getPressedSubmitElement(); - - if ($button && $button->getName() !== 'add') { - [$name, $toRemove] = explode('_', $button->getName(), 2); - $toRemove = (int) $toRemove; - $this->removedOptionNumber = $toRemove; - $optionCount = count($this->options); - - for ($i = $toRemove; $i < $optionCount; $i++) { - $nextCount = $i + 1; - $this->getElement('column' . $nextCount)->setName('column' . $i); - $this->getElement('value' . $nextCount)->setName('value' . $i); - - $this->getElement('remove_' . $nextCount)->setName('remove_' . $i); - } - - unset($this->options[$toRemove]); - - if (count($this->options) === 1) { - $key = key($this->options); - $this->options[$key]->remove($this->getElement('remove_' . $key)); - } - } - } -} diff --git a/application/forms/EventRuleConfigElements/EscalationCondition.php b/application/forms/EventRuleConfigElements/EscalationCondition.php new file mode 100644 index 000000000..2eec71e7e --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationCondition.php @@ -0,0 +1,298 @@ + 'escalation-condition']; + + /** @var EscalationConditionListItem[] Condition list items */ + protected $conditionListItems = []; + + /** @var EventRuleConfigForm */ + protected $configForm; + + /** @var string */ + protected $prefix; + + /** @var string condition filter */ + protected $condition; + + public function __construct(string $prefix, EventRuleConfigForm $configForm) + { + $this->prefix = $prefix; + $this->configForm = $configForm; + + parent::__construct('escalation-condition_' . $this->prefix); + } + + /** + * Set the condition value + * + * @param $id + * @param $condition + * + * @return $this + */ + public function setCondition($condition): self + { + $this->condition = $condition; + + return $this; + } + + /** + * Get the rendered condition + * + * @return string + */ + public function getCondition(): string + { + return $this->condition; + } + + protected function assemble(): void + { + $filters = QueryString::parse($this->condition); + + if ($filters instanceof Filter\Chain) { + $conditionCount = $filters->count(); + } else { + $conditionCount = 1; + + $filters = Filter::all($filters); + } + + $this->addElement('hidden', 'condition-count', ['value' => $conditionCount]); + // Escalation Id to which the condition belongs + $this->addElement('hidden', 'id'); + + /** @var SubmitButtonElement $addCondition */ + $addCondition = $this->createElement( + 'submitButton', + 'add-condition', + [ + 'class' => ['add-button', 'control-button', 'spinner'], + 'label' => new Icon('plus'), + 'title' => $this->translate('Add Condition'), + 'formnovalidate' => true + ] + ); + + $this->registerElement($addCondition); + + $zeroConditions = (string) $this->configForm->getValue('zero-condition-escalation') === $this->prefix; + $configHasZeroConditionEscalation = $this->configForm->hasZeroConditionEscalation(); + if ($zeroConditions && $configHasZeroConditionEscalation) { + $conditionCount = 0; + } elseif ($conditionCount === 0) { + $filters->add(Filter::equal('placeholder', '')); + + $conditionCount += 1; + } + + if ($addCondition->hasBeenPressed()) { + $filters->add(Filter::equal('placeholder', '')); + $conditionCount += 1; + $this->getElement('condition-count')->setValue($conditionCount); + } + + if ($conditionCount === 0) { + $this->addAttributes(['class' => 'zero-escalation-condition']); + $this->addElement($addCondition); + + return; + } + + $this->getAttributes()->remove('class', 'zero-escalation-condition'); + $removePosition = null; + + $position = 1; + $operators = ['=', '>', '>=', '<', '<=', '!=']; + $severityOptions = [ + 'ok' => $this->translate('Ok', 'notification.severity'), + 'debug' => $this->translate('Debug', 'notification.severity'), + 'info' => $this->translate('Information', 'notification.severity'), + 'notice' => $this->translate('Notice', 'notification.severity'), + 'warning' => $this->translate('Warning', 'notification.severity'), + 'err' => $this->translate('Error', 'notification.severity'), + 'crit' => $this->translate('Critical', 'notification.severity'), + 'alert' => $this->translate('Alert', 'notification.severity'), + 'emerg' => $this->translate('Emergency', 'notification.severity') + ]; + + /** @var Filter\Condition $filter */ + foreach ($filters as $filter) { + $filterType = $this->getPopulatedValue('column_' . $position) ?? $filter->getColumn(); + if ($filterType === 'placeholder') { + $filterType = ''; + } + + $typeElement = $this->createElement( + 'select', + 'column_' . $position, + [ + 'class' => ['autosubmit', 'left-operand'], + 'options' => [ + '' => sprintf(' - %s - ', $this->translate('Please choose')), + 'incident_severity' => $this->translate('Incident Severity'), + 'incident_age' => $this->translate('Incident Age') + ], + 'disabledOptions' => [''], + 'required' => true, + 'value' => $filterType, + ] + ); + + $operatorElement = $this->createElement( + 'select', + 'operator_' . $position, + [ + 'class' => ['operator-input', 'autosubmit'], + 'options' => array_combine($operators, $operators), + 'required' => true, + 'value' => QueryString::getRuleSymbol($filter), + ] + ); + + $valName = 'val_' . $position; + $filterValue = $filter->getValue(); + switch ($filterType) { + case 'incident_severity': + $valElement = $this->createElement( + 'select', + $valName, + [ + 'class' => ['autosubmit', 'right-operand'], + 'options' => $severityOptions, + 'value' => $filterValue + ] + ); + + break; + case 'incident_age': + if (array_key_exists($filterValue, $severityOptions)) { + $filterValue = ''; + $this->clearPopulatedValue($valName); + } + + $valElement = $this->createElement( + 'text', + $valName, + [ + 'required' => true, + 'class' => ['autosubmit', 'right-operand'], + 'value' => $filterValue, + 'validators' => [ + new CallbackValidator(function ($value, $validator) { + if (! preg_match('~^\d+(?:\.?\d*)?[hms]{1}$~', $value)) { + $validator->addMessage( + $this->translate( + 'Only numbers with optional fractions (separated by a dot)' + . ' and one of these suffixes are allowed: h, m, s' + ) + ); + + return false; + } + + $validator->clearMessages(); + + return true; + }) + ] + ] + ); + + break; + default: + $valElement = $this->createElement('text', $valName, [ + 'class' => 'right-operand', + 'placeholder' => $this->translate('Please make a decision'), + 'disabled' => true + ]); + } + + $this->registerElement($typeElement); + $this->registerElement($operatorElement); + $this->registerElement($valElement); + + $removeButton = null; + + if (($conditionCount > 1) || ($conditionCount === 1 && ! $configHasZeroConditionEscalation)) { + $removeButton = $this->createRemoveButton($position); + if ($removeButton->hasBeenPressed()) { + $removePosition = $position; + } + } + + (new EventRuleDecorator())->decorate($valElement); + $this->conditionListItems[$position] = new EscalationConditionListItem( + $position, + $typeElement, + $operatorElement, + $valElement, + $removeButton + ); + + $position++; + } + + if ($removePosition) { + $this->getElement('condition-count')->setValue(--$conditionCount); + if ($conditionCount === 1 && $configHasZeroConditionEscalation) { + $idx = $removePosition === 1 ? 2 : 1; + $this->conditionListItems[$idx]->removeRemoveButton(); + $filters->getIterator()->offsetUnset($idx); + } + } + + $this->condition = (new FilterRenderer($filters))->render(); + $this->add(new EscalationConditionList($this->conditionListItems)); + $this->addElement($addCondition); + } + + /** + * Create remove button for the condition in the given position + * + * @param int $count + * + * @return SubmitButtonElement + */ + protected function createRemoveButton(int $count): SubmitButtonElement + { + $removeButton = new SubmitButtonElement( + 'remove', + [ + 'class' => ['remove-button', 'control-button', 'spinner'], + 'label' => new Icon('minus'), + 'title' => $this->translate('Remove'), + 'formnovalidate' => true, + 'value' => (string) $count + ] + ); + + $this->registerElement($removeButton); + + return $removeButton; + } + + public function hasValue(): bool + { + $this->ensureAssembled(); + + return parent::hasValue(); + } +} diff --git a/application/forms/EventRuleConfigElements/EscalationRecipient.php b/application/forms/EventRuleConfigElements/EscalationRecipient.php new file mode 100644 index 000000000..1cfcb07ee --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationRecipient.php @@ -0,0 +1,277 @@ + 'escalation-recipient']; + + /** @var EscalationRecipientListItem[] */ + protected $recipientListItems = []; + + /** @var array */ + protected $recipients = []; + + public function __construct($name) + { + parent::__construct('escalation-recipient_' . $name); + } + + protected function assemble(): void + { + $recipientCount = count($this->recipients); + $this->addElement('hidden', 'recipient-count', ['value' => $recipientCount]); + + $addRecipientButton = $this->createElement( + 'submitButton', + 'add-recipient', + [ + 'class' => ['add-button', 'control-button', 'spinner'], + 'label' => new Icon('plus'), + 'title' => $this->translate('Add Recipient'), + 'formnovalidate' => true + ] + ); + + $this->registerElement($addRecipientButton); + + if ($addRecipientButton->hasBeenPressed()) { + $this->getElement('recipient-count')->setValue(++$recipientCount); + $this->recipients[$recipientCount] = []; + } + + $defaultOption = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; + $recipientOptions = $defaultOption + $this->fetchOptions(); + $channelOptions = $defaultOption + Channel::fetchChannelNames(Database::get()); + $removePosition = null; + + $position = 1; + foreach ($this->recipients as $escalationRecipient) { + $this->addElement( + 'hidden', + 'id_' . $position, + ['value' => $escalationRecipient['id'] ?? null] + ); + + $recipient = array_filter($escalationRecipient, function ($k) { + return in_array($k, ['contact_id', 'contactgroup_id', 'schedule_id']); + }, ARRAY_FILTER_USE_KEY); + + if (empty($recipient)) { + $recipientVal = $this->getPopulatedValue('column_' . $position, ''); + } else { + // Trim the trailing '_id' from the array key + $recipientType = substr(array_key_first($recipient) ?? '', 0, -3); + $recipientVal = $recipientType . '_' . array_shift($recipient); + } + + $col = $this->createElement( + 'select', + 'column_' . $position, + [ + 'class' => ['autosubmit', 'left-operand'], + 'options' => $recipientOptions, + 'disabledOptions' => [''], + 'required' => true, + 'value' => $recipientVal + ] + ); + + $this->registerElement($col); + + if (isset($escalationRecipient['channel_id'])) { + $channelId = (int) $escalationRecipient['channel_id']; + } else { + $channelId = ''; + } + + $val = $this->createElement( + 'select', + 'val_' . $position, + [ + 'class' => ['autosubmit', 'right-operand'], + 'options' => $channelOptions, + 'disabledOptions' => [''], + 'value' => $this->getPopulatedValue('val_' . $position) ?? $channelId + ] + ); + + $recipientVal = $this->getValue('column_' . $position); + if ($recipientVal !== null) { + $recipientType = explode('_', $recipientVal)[0]; + if ($recipientType === 'contact') { + $val->setOptions(['' => $this->translate('Default Channel')] + $channelOptions); + $val->setDisabledOptions([]); + + if ($this->getPopulatedValue('val_' . $position, '') === '') { + $val->addAttributes(['class' => 'default-channel']); + } + } else { + $val->addAttributes(['required' => true]); + } + } else { + $val = $this->createElement('text', 'val_' . $position, [ + 'class' => 'right-operand', + 'placeholder' => $this->translate('Please make a decision'), + 'disabled' => true, + 'value' => $this->getPopulatedValue('val_' . $position) + ]); + } + + $this->registerElement($val); + $removeButton = null; + if ($recipientCount > 1) { + $removeButton = $this->createRemoveButton($position); + if ($removeButton->hasBeenPressed()) { + $removePosition = $position; + } + } + + $this->recipientListItems[$position] = new EscalationRecipientListItem( + $position++, + $col, + $val, + $removeButton + ); + } + + if ($removePosition) { + $recipientCount -= 1; + $this->getElement('recipient-count')->setValue($recipientCount); + if ($recipientCount === 1) { + $idx = $removePosition === 1 ? 2 : 1; + $this->recipientListItems[$idx]->removeRemoveButton(); + } + } + + $this->add(new EscalationRecipientList($this->recipientListItems)); + + $this->addElement($addRecipientButton); + } + + /** + * Fetch recipient options + * + * @return array> + */ + protected function fetchOptions(): array + { + $options = []; + /** @var Contact $contact */ + foreach (Contact::on(Database::get()) as $contact) { + $options[$this->translate('Contacts')]['contact_' . $contact->id] = $contact->full_name; + } + + /** @var Contactgroup $contactgroup */ + foreach (Contactgroup::on(Database::get()) as $contactgroup) { + $options[$this->translate('Contact Groups')]['contactgroup_' . $contactgroup->id] = $contactgroup->name; + } + + /** @var Schedule $schedule */ + foreach (Schedule::on(Database::get()) as $schedule) { + $options[$this->translate('Schedules')]['schedule_' . $schedule->id] = $schedule->name; + } + + return $options; + } + + /** + * Create remove button for the recipient in the given position + * + * @param int $pos + * + * @return SubmitButtonElement + */ + protected function createRemoveButton(int $pos): SubmitButtonElement + { + $removeButton = new SubmitButtonElement( + 'remove', + [ + 'class' => ['remove-button', 'control-button', 'spinner'], + 'label' => new Icon('minus'), + 'title' => $this->translate('Remove'), + 'formnovalidate' => true, + 'value' => (string) $pos + ] + ); + + $this->registerElement($removeButton); + + return $removeButton; + } + + public function hasValue(): bool + { + $this->ensureAssembled(); + + return parent::hasValue(); + } + + public function setRecipients(array $recipients): self + { + $this->recipients = $recipients; + + if (empty($this->recipients)) { + $this->recipients = [0 => []]; + } + + return $this; + } + + /** + * Get recipients of the escalation + * + * @return array> + */ + public function getRecipients(): array + { + /** @var int $count */ + $count = $this->getValue('recipient-count'); + $removePosition = $this->getValue('remove'); + if ($removePosition) { + // This is needed as the count is already reduced when the remove button of a recipient is clicked, but the + // registered element is not yet removed from the form. Hence, needs to be skipped in the loop when fetching + // the recipients + $count += 1; + } + + $values = []; + for ($i = 1; $i <= $count; $i++) { + if ($i === (int) $removePosition) { + continue; + } + + $value = []; + $value['channel_id'] = $this->getValue('val_' . $i); + $value['id'] = $this->getValue('id_' . $i); + + /** @var ?string $recipient */ + $recipient = $this->getValue('column_' . $i); + + if ($recipient === null) { + $values[] = $value; + + continue; + } + + [$recipientType, $id] = explode('_', $recipient, 2); + + $value[$recipientType . '_id'] = $id; + + $values[] = $value; + } + + return $values; + } +} diff --git a/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php b/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php new file mode 100644 index 000000000..81a8c581e --- /dev/null +++ b/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php @@ -0,0 +1,95 @@ + 'config-filter']; + + public function __construct(Url $searchEditorUrl, ?string $filter) + { + $this->searchEditorUrl = $searchEditorUrl; + $this->objectFilter = $filter; + + parent::__construct('config-filter'); + } + + /** + * Get the event rule's object filter + * + * @return ?string + */ + public function getObjectFilter(): ?string + { + return $this->objectFilter; + } + + protected function assemble(): void + { + if (! $this->getObjectFilter()) { + $addFilterButton = new SubmitButtonElement( + 'add-filter', + [ + 'class' => ['add-button', 'control-button', 'spinner'], + 'label' => new Icon('plus'), + 'formnovalidate' => true, + 'title' => $this->translate('Add filter') + ] + ); + $this->registerElement($addFilterButton); + + if ($addFilterButton->hasBeenPressed()) { + $this->removeAttribute('class', 'empty-filter'); + } else { + $this->addAttributes(['class' => 'empty-filter']); + $this->addHtml($addFilterButton); + + return; + } + } + + $editorOpener = new Link( + new Icon('cog'), + $this->searchEditorUrl, + Attributes::create([ + 'class' => ['search-editor-opener', 'control-button'], + 'title' => $this->translate('Adjust Filter'), + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true, + ]) + ); + + $searchBar = new TextElement( + 'searchbar', + [ + 'class' => ['filter-input', 'control-button'], + 'readonly' => true, + 'value' => $this->getObjectFilter() + ] + ); + + $filterElement = new HtmlElement( + 'div', + Attributes::create(['class' => ['search-controls', 'icinga-controls']]) + ); + + $filterElement->addHtml($searchBar, $editorOpener); + + $this->addHtml($filterElement); + } +} diff --git a/application/forms/EventRuleConfigForm.php b/application/forms/EventRuleConfigForm.php new file mode 100644 index 000000000..91d7d2087 --- /dev/null +++ b/application/forms/EventRuleConfigForm.php @@ -0,0 +1,621 @@ + ['event-rule-config', 'icinga-form', 'icinga-controls'], + 'name' => 'event-rule-config-form', + 'id' => 'event-rule-config-form' + ]; + + /** @var array */ + protected $config; + + /** @var Url Search editor URL for the config filter fieldset */ + protected $searchEditorUrl; + + /** @var bool Whether the config has an escalation with no condition */ + protected $hasZeroConditionEscalation = false; + + /** + * Create a new EventRuleConfigForm + * + * @param array $config + * @param Url $searchEditorUrl + */ + public function __construct(array $config, Url $searchEditorUrl) + { + $this->config = $config; + $this->searchEditorUrl = $searchEditorUrl; + + $this->on(self::ON_SENT, function () { + $csrf = $this->getElement('CSRFToken'); + + if ($csrf !== null && $csrf->isValid()) { + $config = array_merge($this->config, $this->getValues()); + if ($config !== $this->config) { + $this->emit(self::ON_CHANGE, [$this]); + } + } + }); + } + + public function isValidEvent($event) + { + if ($event === self::ON_CHANGE) { + return true; + } + + return parent::isValidEvent($event); + } + + public function hasBeenSubmitted() + { + $pressedButton = $this->getPressedSubmitElement(); + + if ($pressedButton && $pressedButton->getName() === 'save') { + return true; + } + + return false; + } + + /** + * Check whether the config has an escalation with no condition + * + * @return bool + */ + public function hasZeroConditionEscalation(): bool + { + return $this->hasZeroConditionEscalation; + } + + protected function assemble(): void + { + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + + // Replicate save button outside the form + $this->addElement( + 'submitButton', + 'save', + [ + 'hidden' => true, + 'class' => 'primary-submit-btn-duplicate' + ] + ); + + // Replicate delete button outside the form + $this->addElement( + 'submitButton', + 'delete', + [ + 'hidden' => true, + 'class' => 'primary-submit-btn-duplicate' + ] + ); + + // Replicate discard_changes button outside the form + $this->addElement( + 'submitButton', + 'discard_changes', + [ + 'hidden' => true, + 'class' => 'primary-submit-btn-duplicate' + ] + ); + + $defaultEscalationPrefix = 1; + + $this->addElement('hidden', 'zero-condition-escalation'); + + if (! isset($this->config['rule_escalation'])) { + $this->getElement('zero-condition-escalation')->setValue($defaultEscalationPrefix); + } + + $configFilter = new EventRuleConfigFilter($this->searchEditorUrl, $this->config['object_filter']); + $this->registerElement($configFilter); + + $addEscalationButton = $this->createElement( + 'submitButton', + 'add-escalation', + [ + 'class' => ['add-button', 'control-button', 'spinner'], + 'label' => new Icon('plus'), + 'title' => $this->translate('Add Escalation'), + 'formnovalidate' => true + ] + ); + + $this->registerElement($addEscalationButton); + $prefixesElement = $this->createElement('hidden', 'prefixes', ['value' => $defaultEscalationPrefix]); + $this->addElement($prefixesElement); + $this->handleAdd(); + + $prefixes = explode(',', $prefixesElement->getValue()); + $escalationCount = count($prefixes); + $zeroConditionEscalation = $this->getValue('zero-condition-escalation'); + $removePosition = null; + $removeEscalationButtons = []; + + if ($escalationCount > 1) { + foreach ($prefixes as $prefix) { + $removeEscalationButtons[$prefix] = $this->createRemoveButton($prefix); + } + + $removePosition = $this->getValue('remove-escalation'); + if ($removePosition && $escalationCount === 2) { + $removeEscalationButtons = []; + } + } + + $escalations = []; + $this->hasZeroConditionEscalation = $zeroConditionEscalation !== null; + + foreach ($prefixes as $key => $prefix) { + if ($removePosition === $prefix) { + if ($zeroConditionEscalation === $prefix) { + $zeroConditionEscalation = null; + $this->hasZeroConditionEscalation = false; + } + + unset($prefixes[$key]); + $this->getElement('prefixes')->setValue(implode(',', $prefixes)); + + continue; + } + + $escalationCondition = (new EscalationCondition($prefix, $this)) + ->setCondition($this->config['rule_escalation'][$prefix]['condition'] ?? ''); + $escalationRecipient = (new EscalationRecipient($prefix)) + ->setRecipients($this->config['rule_escalation'][$prefix]['recipients'] ?? []); + $this->registerElement($escalationCondition); + $this->registerElement($escalationRecipient); + + $escalation = new Escalation( + $escalationCondition, + $escalationRecipient, + $removeEscalationButtons[$prefix] ?? null + ); + + if ($zeroConditionEscalation === $prefix && $escalation->addConditionHasBeenPressed()) { + $this->hasZeroConditionEscalation = false; + $zeroConditionEscalation = null; + } elseif ($escalation->lastConditionHasBeenRemoved()) { + $this->hasZeroConditionEscalation = true; + $zeroConditionEscalation = $prefix; + } + + $escalations[] = $escalation; + } + + $this->getElement('zero-condition-escalation')->setValue($zeroConditionEscalation); + + $this->addHtml( + (new HtmlElement('div', Attributes::create(['class' => 'filter-wrapper']))) + ->addHtml( + (new FlowLine())->getRightArrow(), + $configFilter, + (new FlowLine())->getHorizontalLine() + ) + ); + + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => 'escalations-wrapper']), + new Escalations($escalations), + $addEscalationButton + )); + } + + /** + * Handle addition of escalations + * + * @return void + */ + protected function handleAdd(): void + { + $pressedButton = $this->getPressedSubmitElement(); + + if ($pressedButton && $pressedButton->getName() === 'add-escalation') { + $this->clearPopulatedValue('prefixes'); + $prefixesString = $this->getValue('prefixes', ''); + $prefixesMap = explode(',', $prefixesString); + $escalationFakePos = random_int(-1000, -1); + $prefixesMap[] = $escalationFakePos; + $this->getElement('prefixes') + ->setValue(implode(',', $prefixesMap)); + + if ($this->getValue('zero-condition-escalation') === null) { + $this->getElement('zero-condition-escalation') + ->setValue($escalationFakePos); + } + } + } + + public function populate($values): self + { + if (! isset($values['rule_escalation'])) { + return parent::populate($values); + } + + $values['prefixes'] = $this->getPrefixes(count($values['rule_escalation'])); + $zeroConditionEscalation = array_filter($values['rule_escalation'], function ($escalation) { + return $escalation['condition'] === ''; + }); + + if (! empty($zeroConditionEscalation)) { + $values['zero-condition-escalation'] = array_key_first($zeroConditionEscalation); + } + + foreach ($values['rule_escalation'] as $prefix => $escalation) { + $values['escalation-condition_' . $prefix]['id'] = $escalation['id']; + } + + return parent::populate($values); + } + + /** + * Get the values for the current EventRuleConfigForm + * + * @return array values as name-value pairs + */ + public function getValues(): array + { + $values = []; + $escalations = []; + $prefixesString = $this->getValue('prefixes', ''); + + /** @var string[] $prefixesMap */ + $prefixesMap = explode(',', $prefixesString); + foreach ($prefixesMap as $prefix) { + /** @var EscalationCondition $escalationCondition */ + $escalationCondition = $this->getElement('escalation-condition_' . $prefix); + /** @var EscalationRecipient $escalationRecipient */ + $escalationRecipient = $this->getElement('escalation-recipient_' . $prefix); + $escalations[$prefix]['condition'] = $escalationCondition->getCondition(); + $escalations[$prefix]['id'] = $escalationCondition->getValue('id'); + $escalations[$prefix]['recipients'] = $escalationRecipient->getRecipients(); + } + + /** @var EventRuleConfigFilter $configFilter */ + $configFilter = $this->getElement('config-filter'); + $values['object_filter'] = $configFilter->getObjectFilter(); + $values['rule_escalation'] = $escalations; + + return $values; + } + + /** + * Create remove button for the given escalation position + * + * @param string $prefix + * + * @return SubmitButtonElement + */ + protected function createRemoveButton(string $prefix): SubmitButtonElement + { + /** @var SubmitButtonElement $button */ + $button = $this->createElement( + 'submitButton', + 'remove-escalation', + [ + 'class' => ['remove-escalation', 'remove-button', 'control-button', 'spinner'], + 'label' => new Icon('minus'), + 'formnovalidate' => true, + 'value' => $prefix, + 'title' => $this->translate('Remove escalation') + ] + ); + + $this->registerElement($button); + + return $button; + } + + /** + * Insert to or update event rule in the database and return the id of the event rule + * + * @param int $id The id of the event rule + * @param array $config The new configuration + * + * @return int + */ + public function addOrUpdateRule(int $id, array $config): int + { + $db = Database::get(); + + $db->beginTransaction(); + + if ($id < 0) { + $db->insert('rule', [ + 'name' => $config['name'], + 'timeperiod_id' => $config['timeperiod_id'] ?? null, + 'object_filter' => $config['object_filter'] ?: null, + 'changed_at' => (int) (new DateTime())->format("Uv") + ]); + + $id = $db->lastInsertId(); + } else { + $db->update('rule', [ + 'name' => $config['name'], + 'timeperiod_id' => $config['timeperiod_id'] ?? null, + 'object_filter' => $config['object_filter'] ?: null, + 'changed_at' => (int) (new DateTime())->format("Uv") + ], ['id = ?' => $id]); + } + + $escalationsFromDb = RuleEscalation::on($db) + ->filter(Filter::equal('rule_id', $id)); + + $escalationsInCache = $config['rule_escalation']; + + $escalationsToUpdate = []; + $escalationsToRemove = []; + + /** @var RuleEscalation $escalationFromDB */ + foreach ($escalationsFromDb as $escalationFromDB) { + $escalationId = $escalationFromDB->id; + $escalationInCache = array_filter($escalationsInCache, function (array $element) use ($escalationId) { + /** @var string $idInCache */ + $idInCache = $element['id'] ?? null; + + return (int) $idInCache === $escalationId; + }); + + if ($escalationInCache) { + $position = array_key_first($escalationInCache); + // Escalations in DB to update + $escalationsToUpdate[$position] = $escalationInCache[$position]; + + unset($escalationsInCache[$position]); + } else { + // Escalation in DB to remove + $escalationsToRemove[] = $escalationId; + } + } + + // Escalations to add + $escalationsToAdd = $escalationsInCache; + + if (! empty($escalationsToRemove)) { + $db->update('rule_escalation_recipient', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n']); + $db->update('rule_escalation', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['id IN (?)' => $escalationsToRemove]); + } + + if (! empty($escalationsToAdd)) { + $this->insertOrUpdateEscalations($id, $escalationsToAdd, true); + } + + if (! empty($escalationsToUpdate)) { + $this->insertOrUpdateEscalations($id, $escalationsToUpdate); + } + + $db->commitTransaction(); + + return (int) $id; + } + + /** + * Insert to or update escalations in Db + * + * @param int $ruleId + * @param array> $escalations + * @param bool $insert + * + * @return void + */ + private function insertOrUpdateEscalations(int $ruleId, array $escalations, bool $insert = false): void + { + $db = Database::get(); + foreach ($escalations as $position => $escalationConfig) { + $recipientsFromConfig = $escalationConfig['recipients'] ?? []; + if ($insert) { + $db->insert('rule_escalation', [ + 'rule_id' => $ruleId, + 'position' => $position, + $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, + 'name' => $escalationConfig['name'] ?? null, + 'fallback_for' => $escalationConfig['fallback_for'] ?? null, + 'changed_at' => (int) (new DateTime())->format("Uv") + ]); + + $escalationId = $db->lastInsertId(); + } else { + /** @var string $escalationId */ + $escalationId = $escalationConfig['id']; + $db->update('rule_escalation', [ + 'position' => $position, + $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, + 'name' => $escalationConfig['name'] ?? null, + 'fallback_for' => $escalationConfig['fallback_for'] ?? null, + 'changed_at' => (int) (new DateTime())->format("Uv") + ], ['id = ?' => $escalationId, 'rule_id = ?' => $ruleId]); + + $recipientsToRemove = []; + $recipients = RuleEscalationRecipient::on($db) + ->columns('id') + ->filter(Filter::equal('rule_escalation_id', $escalationId)); + + /** @var RuleEscalationRecipient $recipient */ + foreach ($recipients as $recipient) { + $recipientId = $recipient->id; + $recipientInCache = array_filter( + $recipientsFromConfig, + function (array $element) use ($recipientId) { + /** @var string $idFromCache */ + $idFromCache = $element['id']; + return (int) $idFromCache === $recipientId; + } + ); + + if (empty($recipientInCache)) { + // Recipients to remove from Db not in cache + $recipientsToRemove[] = $recipientId; + } + } + + if (! empty($recipientsToRemove)) { + $db->update('rule_escalation_recipient', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['id IN (?)' => $recipientsToRemove, 'deleted = ?' => 'n']); + } + } + + foreach ($recipientsFromConfig as $recipientConfig) { + $data = [ + 'rule_escalation_id' => $escalationId, + 'channel_id' => $recipientConfig['channel_id'], + 'changed_at' => (int) (new DateTime())->format("Uv") + ]; + + switch (true) { + case isset($recipientConfig['contact_id']): + $data['contact_id'] = $recipientConfig['contact_id']; + $data['contactgroup_id'] = null; + $data['schedule_id'] = null; + + break; + case isset($recipientConfig['contactgroup_id']): + $data['contact_id'] = null; + $data['contactgroup_id'] = $recipientConfig['contactgroup_id']; + $data['schedule_id'] = null; + + break; + case isset($recipientConfig['schedule_id']): + $data['contact_id'] = null; + $data['contactgroup_id'] = null; + $data['schedule_id'] = $recipientConfig['schedule_id']; + + break; + } + + if (! isset($recipientConfig['id'])) { + $db->insert('rule_escalation_recipient', $data); + } else { + $db->update('rule_escalation_recipient', $data, ['id = ?' => $recipientConfig['id']]); + } + } + } + } + + /** + * Get whether the delete button was pressed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'delete'; + } + + /** + * Get whether the discard button was pressed + * + * @return bool + */ + public function hasBeenDiscarded(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'discard_changes'; + } + + /** + * Remove the given event rule + * + * @param int $id + * + * @return void + */ + public function removeRule(int $id): void + { + $db = Database::get(); + $db->beginTransaction(); + $escalations = RuleEscalation::on($db) + ->columns('id') + ->filter(Filter::equal('rule_id', $id)); + + $escalationsToRemove = []; + /** @var RuleEscalation $escalation */ + foreach ($escalations as $escalation) { + $escalationsToRemove[] = $escalation->id; + } + + if (! empty($escalationsToRemove)) { + $db->update('rule_escalation_recipient', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n']); + } + + $db->update('rule_escalation', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'position' => null, + 'deleted' => 'y' + ], ['rule_id = ?' => $id]); + $db->update('rule', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['id = ?' => $id]); + + $db->commitTransaction(); + } + + /** + * Get the prefix map + * + * @param int $escalationCount + * + * @return string + */ + protected function getPrefixes(int $escalationCount): string + { + $prefixesMap = []; + for ($i = 1; $i <= $escalationCount; $i++) { + $prefixesMap[] = $i; + } + + return implode(',', $prefixesMap); + } +} diff --git a/application/forms/EventRuleForm.php b/application/forms/EventRuleForm.php index 54d45b52a..0d4428079 100644 --- a/application/forms/EventRuleForm.php +++ b/application/forms/EventRuleForm.php @@ -14,7 +14,7 @@ class EventRuleForm extends CompatForm use CsrfCounterMeasure; use Translation; - protected function assemble() + protected function assemble(): void { $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); diff --git a/application/forms/RemoveEscalationForm.php b/application/forms/RemoveEscalationForm.php deleted file mode 100644 index 4a9979298..000000000 --- a/application/forms/RemoveEscalationForm.php +++ /dev/null @@ -1,70 +0,0 @@ - ['remove-escalation-form', 'icinga-form', 'icinga-controls'], - ]; - - /** @var bool */ - private $disableRemoveButtton; - - protected function assemble() - { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - $this->addElement($this->createUidElement()); - - $this->addElement( - 'submitButton', - 'remove', - [ - 'class' => ['remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus') - ] - ); - - $this->getElement('remove') - ->getAttributes() - ->registerAttributeCallback('disabled', function () { - return $this->disableRemoveButtton; - }) - ->registerAttributeCallback('title', function () { - if ($this->disableRemoveButtton) { - return $this->translate( - 'There exist active incidents for this escalation and hence cannot be removed' - ); - } - - return $this->translate('Remove escalation'); - }); - } - - /** - * Method to set disabled state of remove button - * - * @param bool $state - * - * @return $this - */ - public function setRemoveButtonDisabled(bool $state = false) - { - $this->disableRemoveButtton = $state; - - return $this; - } -} diff --git a/application/forms/SaveEventRuleForm.php b/application/forms/SaveEventRuleForm.php deleted file mode 100644 index 32d887b4e..000000000 --- a/application/forms/SaveEventRuleForm.php +++ /dev/null @@ -1,580 +0,0 @@ - ['icinga-controls', 'save-event-rule'], - 'name' => 'save-event-rule' - ]; - - /** @var bool Whether to disable the submit button */ - protected $disableSubmitButton = false; - - /** @var string The label to use on the submit button */ - protected $submitLabel; - - /** @var bool Whether to show a button to delete the rule */ - protected $showRemoveButton = false; - - /** @var bool Whether to show a button to dismiss cached changes */ - protected $showDismissChangesButton = false; - - /** @var int The rule id */ - protected $ruleId; - - /** - * Create a new SaveEventRuleForm - */ - public function __construct() - { - $this->on(self::ON_SENT, function () { - if ($this->hasBeenRemoved()) { - $this->emit(self::ON_REMOVE, [$this]); - } - }); - } - - public function hasBeenSubmitted(): bool - { - return $this->hasBeenSent() && $this->getPressedSubmitElement() !== null; - } - - /** - * Set whether to enable or disable the submit button - * - * @param bool $state - * - * @return $this - */ - public function setSubmitButtonDisabled(bool $state = true): self - { - $this->disableSubmitButton = $state; - - return $this; - } - - /** - * Set the submit label - * - * @param string $label - * - * @return $this - */ - public function setSubmitLabel(string $label): self - { - $this->submitLabel = $label; - - return $this; - } - - /** - * Get the submit label - * - * @return string - */ - public function getSubmitLabel(): string - { - return $this->submitLabel ?? t('Add Event Rule'); - } - - /** - * Set whether to show a button to delete the rule - * - * @param bool $state - * - * @return $this - */ - public function setShowRemoveButton(bool $state = true): self - { - $this->showRemoveButton = $state; - - return $this; - } - - /** - * Set whether to show a button to dismiss cached changes - * - * @param bool $state - * - * @return $this - */ - public function setShowDismissChangesButton(bool $state = true): self - { - $this->showDismissChangesButton = $state; - - return $this; - } - - /** - * Get whether the user pushed the remove button - * - * @return bool - */ - private function hasBeenRemoved(): bool - { - $btn = $this->getPressedSubmitElement(); - $csrf = $this->getElement('CSRFToken'); - - return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove'; - } - - public function isValidEvent($event) - { - if ($event === self::ON_REMOVE) { - return true; - } - - return parent::isValidEvent($event); - } - - protected function assemble() - { - $this->addElement($this->createUidElement()); - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - - $this->addElement('submit', 'submit', [ - 'label' => $this->getSubmitLabel(), - 'class' => 'btn-primary' - ]); - - $this->getElement('submit') - ->getAttributes() - ->registerAttributeCallback('disabled', function () { - return $this->disableSubmitButton; - }); - - $additionalButtons = []; - if ($this->showRemoveButton) { - $removeBtn = $this->createElement('submit', 'remove', [ - 'label' => $this->translate('Delete Event Rule'), - 'class' => 'btn-remove', - 'formnovalidate' => true - ]); - $this->registerElement($removeBtn); - - $additionalButtons[] = $removeBtn; - } - - if ($this->showDismissChangesButton) { - $clearCacheBtn = $this->createElement('submit', 'discard_changes', [ - 'label' => $this->translate('Discard Changes'), - 'class' => 'btn-discard-changes', - 'formnovalidate' => true - ]); - $this->registerElement($clearCacheBtn); - $additionalButtons[] = $clearCacheBtn; - } - - $this->getElement('submit')->prependWrapper((new HtmlDocument())->setHtmlContent(...$additionalButtons)); - } - - /** - * Add a new event rule with the given configuration - * - * @param array $config - * - * @return int The id of the new event rule - */ - public function addRule(array $config): int - { - if (! isset($config['name'])) { - throw new Exception('Name of the event rule is not set'); - } - - $db = Database::get(); - - $db->beginTransaction(); - - $changedAt = (int) (new DateTime())->format("Uv"); - $db->insert('rule', [ - 'name' => $config['name'], - 'timeperiod_id' => $config['timeperiod_id'] ?? null, - 'object_filter' => $config['object_filter'] ?? null, - 'changed_at' => $changedAt - ]); - $ruleId = $db->lastInsertId(); - - foreach ($config['rule_escalation'] ?? [] as $position => $escalationConfig) { - $db->insert('rule_escalation', [ - 'rule_id' => $ruleId, - 'position' => $position, - $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, - 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null, - 'changed_at' => $changedAt - ]); - $escalationId = $db->lastInsertId(); - - foreach ($escalationConfig['recipient'] ?? [] as $recipientConfig) { - $data = [ - 'rule_escalation_id' => $escalationId, - 'channel_id' => $recipientConfig['channel_id'], - 'changed_at' => $changedAt - ]; - - switch (true) { - case isset($recipientConfig['contact_id']): - $data['contact_id'] = $recipientConfig['contact_id']; - break; - case isset($recipientConfig['contactgroup_id']): - $data['contactgroup_id'] = $recipientConfig['contactgroup_id']; - break; - case isset($recipientConfig['schedule_id']): - $data['schedule_id'] = $recipientConfig['schedule_id']; - break; - } - - $db->insert('rule_escalation_recipient', $data); - } - } - - $db->commitTransaction(); - - return $ruleId; - } - - /** - * Insert to or update Escalations and its recipients in Db - * - * @param $ruleId - * @param array $escalations - * @param Connection $db - * @param bool $insert - * - * @return void - */ - private function insertOrUpdateEscalations($ruleId, array $escalations, Connection $db, bool $insert = false): void - { - $changedAt = (int) (new DateTime())->format("Uv"); - foreach ($escalations as $position => $escalationConfig) { - if ($insert) { - $db->insert('rule_escalation', [ - 'rule_id' => $ruleId, - 'position' => $position, - $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, - 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null, - 'changed_at' => $changedAt - ]); - - $escalationId = $db->lastInsertId(); - } else { - $escalationId = $escalationConfig['id']; - $db->update('rule_escalation', [ - 'position' => $position, - $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, - 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null, - 'changed_at' => $changedAt - ], ['id = ?' => $escalationId, 'rule_id = ?' => $ruleId]); - $recipientsToRemove = []; - - $recipients = RuleEscalationRecipient::on($db) - ->columns('id') - ->filter(Filter::equal('rule_escalation_id', $escalationId)); - - foreach ($recipients as $recipient) { - $recipientId = $recipient->id; - $recipientInCache = array_filter( - $escalationConfig['recipient'], - function (array $element) use ($recipientId) { - return (int) $element['id'] === $recipientId; - } - ); - - if (empty($recipientInCache)) { - // Recipients to remove from Db not in cache - $recipientsToRemove[] = $recipientId; - } - } - - if (! empty($recipientsToRemove)) { - $db->update( - 'rule_escalation_recipient', - ['changed_at' => $changedAt, 'deleted' => 'y'], - ['id IN (?)' => $recipientsToRemove, 'deleted = ?' => 'n'] - ); - } - } - - foreach ($escalationConfig['recipient'] ?? [] as $recipientConfig) { - $data = [ - 'rule_escalation_id' => $escalationId, - 'channel_id' => $recipientConfig['channel_id'], - 'changed_at' => $changedAt - ]; - - switch (true) { - case isset($recipientConfig['contact_id']): - $data['contact_id'] = $recipientConfig['contact_id']; - $data['contactgroup_id'] = null; - $data['schedule_id'] = null; - break; - case isset($recipientConfig['contactgroup_id']): - $data['contact_id'] = null; - $data['contactgroup_id'] = $recipientConfig['contactgroup_id']; - $data['schedule_id'] = null; - break; - case isset($recipientConfig['schedule_id']): - $data['contact_id'] = null; - $data['contactgroup_id'] = null; - $data['schedule_id'] = $recipientConfig['schedule_id']; - break; - } - - if (! isset($recipientConfig['id'])) { - $db->insert('rule_escalation_recipient', $data); - } else { - $db->update( - 'rule_escalation_recipient', - $data + ['changed_at' => $changedAt], - ['id = ?' => $recipientConfig['id']] - ); - } - } - } - } - - /** - * Edit an existing event rule - * - * @param int $id The id of the event rule - * @param array $config The new configuration - * - * @return void - */ - public function editRule(int $id, array $config): void - { - $this->ruleId = $id; - - $db = Database::get(); - - $db->beginTransaction(); - - $storedValues = $this->fetchDbValues(); - - $values = $this->getChanges($storedValues, $config); - - $data = array_filter([ - 'name' => $values['name'] ?? null - ]); - - if (array_key_exists('object_filter', $values)) { - $data['object_filter'] = $values['object_filter']; - } - - $changedAt = (int) (new DateTime())->format("Uv"); - if (! empty($data)) { - $db->update('rule', $data + ['changed_at' => $changedAt], ['id = ?' => $id]); - } - - if (! isset($values['rule_escalation'])) { - $db->commitTransaction(); - - return; - } - - $escalationsInCache = $config['rule_escalation']; - - $escalationsToUpdate = []; - $escalationsToRemove = []; - - foreach ($storedValues['rule_escalation'] as $escalationInDB) { - $escalationId = $escalationInDB['id']; - $escalationInCache = array_filter($escalationsInCache, function (array $element) use ($escalationId) { - return (int) $element['id'] === $escalationId; - }); - - if ($escalationInCache) { - $position = key($escalationInCache); - // Escalations in DB to update - $escalationsToUpdate[$position] = $escalationInCache[$position]; - unset($escalationsInCache[$position]); - } else { - // Escalation in DB to remove - $escalationsToRemove[] = $escalationId; - } - } - - // Escalations to add - $escalationsToAdd = $escalationsInCache; - - $markAsDeleted = ['changed_at' => $changedAt, 'deleted' => 'y']; - if (! empty($escalationsToRemove)) { - $db->update( - 'rule_escalation_recipient', - $markAsDeleted, - ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n'] - ); - - $db->update( - 'rule_escalation', - $markAsDeleted + ['position' => null], - ['id IN (?)' => $escalationsToRemove] - ); - } - - if (! empty($escalationsToAdd)) { - $this->insertOrUpdateEscalations($id, $escalationsToAdd, $db, true); - } - - if (! empty($escalationsToUpdate)) { - $this->insertOrUpdateEscalations($id, $escalationsToUpdate, $db); - } - - $db->commitTransaction(); - } - - /** - * Remove the given event rule - * - * @param int $id - * - * @return void - */ - public function removeRule(int $id): void - { - $db = Database::get(); - - $db->beginTransaction(); - - $escalationsToRemove = $db->fetchCol( - RuleEscalation::on($db) - ->columns('id') - ->filter(Filter::equal('rule_id', $id)) - ->assembleSelect() - ); - - $markAsDeleted = ['changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y']; - if (! empty($escalationsToRemove)) { - $db->update( - 'rule_escalation_recipient', - $markAsDeleted, - ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n'] - ); - } - - $db->update('rule_escalation', $markAsDeleted + ['position' => null], ['rule_id = ?' => $id]); - $db->update('rule', $markAsDeleted, ['id = ?' => $id]); - - $db->commitTransaction(); - } - - protected function onError() - { - foreach ($this->getMessages() as $message) { - if ($message instanceof Exception) { - Notification::error($this->translate($message->getMessage())); - } - } - } - - /** - * Fetch the values from the database - * - * @return array - * - * @throws HttpNotFoundException - */ - private function fetchDbValues(): array - { - $query = Rule::on(Database::get()) - ->columns(['id', 'name', 'object_filter']) - ->filter(Filter::equal('id', $this->ruleId)); - - $rule = $query->first(); - if ($rule === null) { - throw new HttpNotFoundException($this->translate('Rule not found')); - } - - $config = iterator_to_array($rule); - - $ruleEscalations = $rule - ->rule_escalation - ->withoutColumns(['changed_at', 'deleted']); - - foreach ($ruleEscalations as $re) { - foreach ($re as $k => $v) { - $config[$re->getTableName()][$re->position][$k] = $v; - } - - $escalationRecipients = $re - ->rule_escalation_recipient - ->withoutColumns(['changed_at', 'deleted']); - - foreach ($escalationRecipients as $recipient) { - $config[$re->getTableName()][$re->position]['recipient'][] = iterator_to_array($recipient); - } - } - - if (! isset($config['rule_escalation'])) { - $config['rule_escalation'] = []; - } - - $config['showSearchbar'] = ! empty($config['object_filter']); - - return $config; - } - - /** - * Get the newly made changes - * - * @return array - */ - public function getChanges(array $storedValues, array $formValues): array - { - unset($formValues['conditionPlusButtonPosition']); - $dbValuesToCompare = array_intersect_key($storedValues, $formValues); - - if (count($formValues, COUNT_RECURSIVE) < count($dbValuesToCompare, COUNT_RECURSIVE)) { - // fewer values in the form than in the db, escalation(s) has been removed - if ($formValues['name'] === $dbValuesToCompare['name']) { - unset($formValues['name']); - } - - if ($formValues['object_filter'] === $dbValuesToCompare['object_filter']) { - unset($formValues['object_filter']); - } - - return $formValues; - } - - $checker = static function ($a, $b) use (&$checker) { - if (! is_array($a) || ! is_array($b)) { - return $a <=> $b; - } - - return empty(array_udiff_assoc($a, $b, $checker)) ? 0 : 1; - }; - - return array_udiff_assoc($formValues, $dbValuesToCompare, $checker); - } -} diff --git a/library/Notifications/Model/RuleEscalationRecipient.php b/library/Notifications/Model/RuleEscalationRecipient.php index a3cc2660c..8551ab796 100644 --- a/library/Notifications/Model/RuleEscalationRecipient.php +++ b/library/Notifications/Model/RuleEscalationRecipient.php @@ -28,7 +28,6 @@ * @property Query|Contactgroup $contactgroup * @property Query|Channel $channel */ - class RuleEscalationRecipient extends Model { public function getTableName(): string diff --git a/library/Notifications/Widget/Escalations.php b/library/Notifications/Widget/Escalations.php deleted file mode 100644 index de216093d..000000000 --- a/library/Notifications/Widget/Escalations.php +++ /dev/null @@ -1,64 +0,0 @@ - 'escalations']; - - protected $tag = 'div'; - - protected $config; - - private $escalations = []; - - protected function assemble() - { - $this->add($this->escalations); - } - - public function addEscalation(int $position, array $escalation, ?RemoveEscalationForm $removeEscalationForm = null) - { - $flowLine = (new FlowLine())->getRightArrow(); - - if ( - in_array( - 'count-zero-escalation-condition-form', - $escalation[0]->getAttributes()->get('class')->getValue() - ) - ) { - $flowLine->addAttributes(['class' => 'right-arrow-long']); - } - - if ($removeEscalationForm) { - $this->escalations[$position] = Html::tag( - 'div', - ['class' => 'escalation'], - [ - $removeEscalationForm, - $flowLine, - $escalation[0], - $flowLine, - $escalation[1], - ] - ); - } else { - $this->escalations[$position] = Html::tag( - 'div', - ['class' => 'escalation'], - [ - $flowLine->addAttributes(['class' => 'right-arrow-one-escalation']), - $escalation[0], - $flowLine, - $escalation[1] - ] - ); - } - } -} diff --git a/library/Notifications/Widget/EventRuleConfig.php b/library/Notifications/Widget/EventRuleConfig.php deleted file mode 100644 index 5adc36bce..000000000 --- a/library/Notifications/Widget/EventRuleConfig.php +++ /dev/null @@ -1,417 +0,0 @@ - 'event-rule-detail' - ]; - - public const ON_CHANGE = 'on_change'; - - protected $tag = 'div'; - - /** @var Form[] */ - private $forms; - - /** @var array The config */ - protected $config; - - /** @var Url The url to open the SearchEditor at */ - protected $searchEditorUrl; - - /** @var array> */ - private $escalationForms = []; - - /** @var array */ - private $removeEscalationForms; - - /** @var int */ - private $numEscalations; - - public function __construct(Url $searchEditorUrl, $config = []) - { - $this->searchEditorUrl = $searchEditorUrl; - $this->setConfig($config); - - $this->createForms(); - } - - protected function createForms(): void - { - $config = $this->getConfig(); - $addFilter = (new AddFilterForm()) - ->on(Form::ON_SENT, function () { - $this->config['showSearchbar'] = true; - - $this->emit(self::ON_CHANGE, [$this]); - }); - - $escalations = $config['rule_escalation'] ?? [1 => ['id' => $this->generateFakeEscalationId()]]; - - if (! isset($this->config['rule_escalation'])) { - $this->config['rule_escalation'] = $escalations; - } - - $addEscalation = (new AddEscalationForm()) - ->on(AddEscalationForm::ON_SENT, function () use ($escalations) { - $newPosition = count($escalations) + 1; - $this->config['rule_escalation'][$newPosition] = ['id' => $this->generateFakeEscalationId()]; - if ($this->config['conditionPlusButtonPosition'] === null) { - $this->config['conditionPlusButtonPosition'] = $newPosition; - } - - $this->removeEscalationForms[$newPosition] = $this->createRemoveEscalationForm($newPosition); - - if ($newPosition === 2) { - $this->removeEscalationForms[1] = $this->createRemoveEscalationForm(1); - $this->forms[] = $this->removeEscalationForms[1]; - } - - $this->escalationForms[$newPosition] = [ - $this->createConditionForm($newPosition), - $this->createRecipientForm($newPosition) - ]; - - $this->emit(self::ON_CHANGE, [$this]); - }); - - $this->forms = [ - $addFilter, - $addEscalation - ]; - - foreach ($escalations as $position => $escalation) { - /** @var int $position */ - $values = explode('|', $escalation['condition'] ?? ''); - $escalationCondition = $this->createConditionForm($position, $values); - - $values = $escalation['recipient'] ?? []; - $escalationRecipient = $this->createRecipientForm($position, $values); - - $this->escalationForms[$position] = [ - $escalationCondition, - $escalationRecipient - ]; - - $this->forms[] = $escalationCondition; - $this->forms[] = $escalationRecipient; - - if (count($escalations) > 1) { - $removeEscalation = $this->createRemoveEscalationForm($position); - - $this->forms[] = $removeEscalation; - $this->removeEscalationForms[$position] = $removeEscalation; - } - } - } - - /** - * Create and return the SearchEditor - * - * @return SearchEditor - * - * @throws ProgrammingError - */ - public static function createSearchEditor(): SearchEditor - { - $editor = new SearchEditor(); - - $editor->setAction(Url::fromRequest()->getAbsoluteUrl()); - - $editor->setSuggestionUrl(Url::fromPath( - "notifications/event-rule/complete", - ['_disableLayout' => true, 'showCompact' => true, 'id' => Url::fromRequest()->getParams()->get('id')] - )); - - return $editor; - } - - public static function createFilterString($filters): ?string - { - foreach ($filters as $filter) { - if ($filter instanceof Filter\Chain) { - self::createFilterString($filter); - } elseif (empty($filter->getValue())) { - $filter->setValue(true); - } - } - - if ($filters instanceof Filter\Condition && empty($filters->getValue())) { - $filters->setValue(true); - } - - $filterStr = QueryString::render($filters); - - return ! empty($filterStr) ? $filterStr : null; - } - - public function getForms(): array - { - return $this->forms; - } - - protected function assemble() - { - [$addFilter, $addEscalation] = $this->forms; - - $addFilterButtonOrSearchBar = $addFilter; - $horizontalLine = (new FlowLine())->getHorizontalLine(); - if (! empty($this->config['showSearchbar'])) { - $editorOpener = new Link( - new Icon('cog'), - $this->searchEditorUrl, - Attributes::create([ - 'class' => 'search-editor-opener control-button', - 'title' => t('Adjust Filter'), - 'data-icinga-modal' => true, - 'data-no-icinga-ajax' => true, - ]) - ); - - $searchBar = new TextElement( - 'searchbar', - [ - 'class' => 'filter-input control-button', - 'readonly' => true, - 'value' => isset($this->config['object_filter']) - ? rawurldecode($this->config['object_filter']) - : null - ] - ); - - $addFilterButtonOrSearchBar = Html::tag('div', ['class' => 'search-controls icinga-controls']); - $addFilterButtonOrSearchBar->add([$searchBar, $editorOpener]); - } else { - $horizontalLine->getAttributes() - ->add(['class' => 'horizontal-line-long']); - } - - $this->add([ - (new FlowLine())->getRightArrow(), - $addFilterButtonOrSearchBar, - $horizontalLine - ]); - - $escalations = new Escalations(); - - foreach ($this->escalationForms as $position => $escalation) { - if (isset($this->removeEscalationForms[$position])) { - $escalations->addEscalation($position, $escalation, $this->removeEscalationForms[$position]); - } else { - $escalations->addEscalation($position, $escalation); - } - } - - $escalationswithAdd = Html::tag('div', ['class' => 'escalations-with-add-form']); - - $escalationswithAdd->add([ - $escalations, - $addEscalation - ]); - - $this->add($escalationswithAdd); - } - - public function getConfig(): ?array - { - return $this->config; - } - - public function setConfig($config): self - { - $this->config = $config; - - return $this; - } - - public function isValid(): bool - { - foreach ($this->escalationForms as $escalation) { - [$conditionForm, $recipientForm] = $escalation; - - if (! $conditionForm->isValid() || ! $recipientForm->isValid()) { - return false; - } - } - - return true; - } - - private function createConditionForm(int $position, array $values = []): EscalationConditionForm - { - $cnt = empty(array_filter($values)) ? null : count($values); - - if (! array_key_exists('conditionPlusButtonPosition', $this->config)) { - //the default position of add condition button - $pos = null; - foreach ($this->config['rule_escalation'] as $p => $v) { - if (empty($v['condition'])) { - $pos = $p; - break; - } - } - - $this->config['conditionPlusButtonPosition'] = $pos; - } - - if ($cnt === null && $this->config['conditionPlusButtonPosition'] !== $position) { - $cnt = 1; - } - - $form = (new EscalationConditionForm($cnt)) - ->addAttributes(['name' => 'escalation-condition-form-' . $position]) - ->deleteRemoveButton($this->config['conditionPlusButtonPosition'] !== null) - ->on(Form::ON_SENT, function ($form) use ($position) { - $values = $form->getValues(); - if ( - $form->isAddButtonPressed() - && $this->config['conditionPlusButtonPosition'] === $position - && empty($this->config['rule_escalation'][$position]['condition']) - ) { - $this->config['conditionPlusButtonPosition'] = null; - } - if (empty($values)) { - $this->config['conditionPlusButtonPosition'] = $position; - } - - $this->config['rule_escalation'][$position]['condition'] = $values; - - $this->emit(self::ON_CHANGE, [$this]); - }); - - if ($cnt !== null) { - $form->populate($values); - } else { - $form->addAttributes(['class' => 'count-zero-escalation-condition-form']); - } - - return $form; - } - - private function createRecipientForm(int $position, array $values = []): EscalationRecipientForm - { - $cnt = empty(array_filter($values)) ? null : count($values); - $form = (new EscalationRecipientForm($cnt)) - ->addAttributes(['name' => 'escalation-recipient-form-' . $position]) - ->on(Form::ON_SENT, function ($form) use ($position) { - $this->config['rule_escalation'][$position]['recipient'] = $form->getValues(); - - $this->emit(self::ON_CHANGE, [$this]); - }); - - if ($cnt !== null) { - $form->populate($values); - } - - return $form; - } - - private function createRemoveEscalationForm(int $position): RemoveEscalationForm - { - $escalationId = $this->config['rule_escalation'][$position]['id']; - - $incident = Incident::on(Database::get()) - ->with('rule_escalation'); - - $disableRemoveButton = false; - if (is_int($escalationId)) { - $incident->filter(Filter::equal('rule_escalation.id', $escalationId)); - if ($incident->count() > 0) { - $disableRemoveButton = true; - } - } - - - $form = (new RemoveEscalationForm()) - ->addAttributes(['name' => 'remove-escalation-form-' . $escalationId]) - ->setRemoveButtonDisabled($disableRemoveButton) - ->on(Form::ON_SENT, function ($form) use ($position) { - unset($this->config['rule_escalation'][$position]); - unset($this->escalationForms[$position]); - unset($this->removeEscalationForms[$position]); - - if ($this->config['conditionPlusButtonPosition'] === $position) { - $this->config['conditionPlusButtonPosition'] = null; - } elseif ($this->config['conditionPlusButtonPosition'] > $position) { - $this->config['conditionPlusButtonPosition'] -= 1; - } - - if (! empty($this->config['rule_escalation'])) { - $this->config['rule_escalation'] = array_combine( - range( - 1, - count($this->config['rule_escalation']) - ), - array_values($this->config['rule_escalation']) - ); - } - - if (! empty($this->removeEscalationForms)) { - /** @var array $removeEscalationForms */ - $removeEscalationForms = array_combine( - range( - 1, - count($this->removeEscalationForms) - ), - array_values($this->removeEscalationForms) - ); - $this->removeEscalationForms = $removeEscalationForms; - } - - if (! empty($this->escalationForms)) { - /** @var array> $escalationForms */ - $escalationForms = array_combine( - range( - 1, - count($this->escalationForms) - ), - array_values($this->escalationForms) - ); - $this->escalationForms = $escalationForms; - } - - $numEscalation = count($this->escalationForms); - if ($numEscalation === 1) { - unset($this->removeEscalationForms[1]); - } - - $this->emit(self::ON_CHANGE, [$this]); - }); - - return $form; - } - - private function generateFakeEscalationId(): string - { - return bin2hex(random_bytes(4)); - } -} diff --git a/library/Notifications/Widget/ItemList/Escalation.php b/library/Notifications/Widget/ItemList/Escalation.php new file mode 100644 index 000000000..e1fc676b0 --- /dev/null +++ b/library/Notifications/Widget/ItemList/Escalation.php @@ -0,0 +1,94 @@ + 'escalation']; + + protected $tag = 'li'; + + /** @var ?SubmitButtonElement Remove button of the escalation */ + protected $removeButton; + + /** @var EscalationCondition Escalation condition fieldset */ + protected $condition; + + /** @var EscalationRecipient Escalation recipient fieldset */ + protected $recipient; + + /** + * Create the escalation list item + * + * @param EscalationCondition $condition + * @param EscalationRecipient $recipient + * @param ?SubmitButtonElement $removeButton + */ + public function __construct( + EscalationCondition $condition, + EscalationRecipient $recipient, + ?SubmitButtonElement $removeButton + ) { + $this->condition = $condition; + $this->recipient = $recipient; + $this->removeButton = $removeButton; + } + + /** + * Check if the add button of the condition fieldset has been pressed + * + * @return bool + */ + public function addConditionHasBeenPressed(): bool + { + return $this->condition->getPopulatedValue('add-condition') === 'y'; + } + + /** + * Check if the last condition of the escalation has been removed + * + * @return bool + */ + public function lastConditionHasBeenRemoved(): bool + { + return $this->condition->getPopulatedValue('condition-count') === '1' + && $this->condition->getPopulatedValue('remove') === '1'; + } + + /** + * Create first component of the escalation widget + * + * @return FlowLine|SubmitButtonElement + */ + protected function createFirstComponent() + { + if ($this->removeButton === null) { + return (new FlowLine())->getHorizontalLine(); + } + + return $this->removeButton; + } + + protected function assemble(): void + { + $firstComponent = $this->createFirstComponent(); + if ($firstComponent) { + $this->addHtml($firstComponent); + } + + $this->addHtml( + (new FlowLine())->getRightArrow(), + $this->condition, + (new FlowLine())->getRightArrow(), + $this->recipient + ); + } +} diff --git a/library/Notifications/Widget/ItemList/EscalationConditionList.php b/library/Notifications/Widget/ItemList/EscalationConditionList.php new file mode 100644 index 000000000..f19b51687 --- /dev/null +++ b/library/Notifications/Widget/ItemList/EscalationConditionList.php @@ -0,0 +1,47 @@ + 'escalation-condition-list']; + + protected $tag = 'ul'; + + /** @var EscalationConditionListItem[] Condition list items */ + protected $conditions; + + /** + * Create a list of escalation conditions + * + * @param EscalationConditionListItem[] $conditions + */ + public function __construct(array $conditions) + { + $this->conditions = $conditions; + } + + protected function assemble(): void + { + $removedPosition = null; + foreach ($this->conditions as $position => $condition) { + if ($condition->hasBeenRemoved()) { + $removedPosition = $position; + + continue; + } + + if ($removedPosition !== null) { + $condition->setPosition($position - 1); + } + + if ($position !== $removedPosition) { + $this->addHtml($condition); + } + } + } +} diff --git a/library/Notifications/Widget/ItemList/EscalationConditionListItem.php b/library/Notifications/Widget/ItemList/EscalationConditionListItem.php new file mode 100644 index 000000000..3205afc97 --- /dev/null +++ b/library/Notifications/Widget/ItemList/EscalationConditionListItem.php @@ -0,0 +1,105 @@ + 'escalation-condition-list-item']; + + protected $tag = 'li'; + + /** @var ?SubmitButtonElement Remove button for the recipient */ + protected $removeButton; + + /** @var FormElement Condition type */ + protected $conditionType; + + /** @var FormElement Operator used for the condition */ + protected $operator; + + /** @var FormElement Condition value */ + protected $conditionVal; + + /** @var int Position of the condition in the condition list */ + protected $position; + + /** + * Create the condition list item of the escalation + * + * @param FormElement $conditionType + * @param FormElement $operator + * @param FormElement $conditionVal + * @param ?SubmitButtonElement $removeButton + */ + public function __construct( + int $position, + FormElement $conditionType, + FormElement $operator, + FormElement $conditionVal, + ?SubmitButtonElement $removeButton + ) { + $this->position = $position; + $this->conditionType = $conditionType; + $this->operator = $operator; + $this->conditionVal = $conditionVal; + $this->removeButton = $removeButton; + } + + /** + * Return whether the condition has been removed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + return $this->removeButton && $this->removeButton->hasBeenPressed(); + } + + /** + * Set the position of the condition list item + * + * @param int $position + * + * @return $this + */ + public function setPosition(int $position): self + { + $this->position = $position; + + return $this; + } + + /** + * Removes the remove button from the list item + * + * @return $this + */ + public function removeRemoveButton(): self + { + $this->removeButton = null; + + return $this; + } + + protected function assemble(): void + { + $this->conditionType->setAttribute('name', 'column_' . $this->position); + $this->operator->setAttribute('name', 'operator_' . $this->position); + $this->conditionVal->setAttribute('name', 'val_' . $this->position); + + $this->addHtml($this->conditionType, $this->operator, $this->conditionVal); + if ($this->removeButton) { + $this->removeButton->setSubmitValue((string) $this->position); + $this->addHtml($this->removeButton); + } + } +} diff --git a/library/Notifications/Widget/ItemList/EscalationRecipientList.php b/library/Notifications/Widget/ItemList/EscalationRecipientList.php new file mode 100644 index 000000000..d61f89834 --- /dev/null +++ b/library/Notifications/Widget/ItemList/EscalationRecipientList.php @@ -0,0 +1,49 @@ + 'escalation-recipient-list']; + + protected $tag = 'ul'; + + /** @var EscalationRecipientListItem[] Recipient list items of the escalation */ + protected $recipients; + + /** + * Create a list of escalation recipients + * + * @param EscalationRecipientListItem[] $recipients + */ + public function __construct(array $recipients) + { + $this->recipients = $recipients; + } + + protected function assemble(): void + { + $removedPosition = null; + $recipientCount = count($this->recipients); + foreach ($this->recipients as $position => $recipient) { + if ($recipient->hasBeenRemoved()) { + $removedPosition = $position; + --$recipientCount; + + continue; + } + + if ($removedPosition !== null) { + $recipient->setPosition($position - 1); + } + + if ($position !== $removedPosition) { + $this->addHtml($recipient); + } + } + } +} diff --git a/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php b/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php new file mode 100644 index 000000000..6d6e5a529 --- /dev/null +++ b/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php @@ -0,0 +1,100 @@ + 'escalation-recipient-list-item']; + + protected $tag = 'li'; + + /** @var ?SubmitButtonElement Remove button for the recipient */ + protected $removeButton; + + /** @var FormElement Recipient name */ + protected $recipient; + + /** @var FormElement Recipient channel */ + protected $channel; + + /** @var int Position of the recipient in the recipient list */ + protected $position; + + /** + * Create the recipient list item of the escalation + * + * @param int $position + * @param FormElement $recipient + * @param FormElement $channel + * @param ?SubmitButtonElement $removeButton + */ + public function __construct( + int $position, + FormElement $recipient, + FormElement $channel, + ?SubmitButtonElement $removeButton + ) { + $this->position = $position; + $this->recipient = $recipient; + $this->channel = $channel; + $this->removeButton = $removeButton; + } + + /** + * Return whether the condition has been removed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + return $this->removeButton && $this->removeButton->hasBeenPressed(); + } + + /** + * Set the position of the condition list item + * + * @param int $position + * + * @return $this + */ + public function setPosition(int $position): self + { + $this->position = $position; + + return $this; + } + + /** + * Removes the remove button from the list item + * + * @return $this + */ + public function removeRemoveButton(): self + { + $this->removeButton = null; + + return $this; + } + + protected function assemble(): void + { + $this->recipient->setAttribute('name', 'column_' . $this->position); + $this->channel->setAttribute('name', 'val_' . $this->position); + + $this->addHtml($this->recipient, $this->channel); + if ($this->removeButton) { + $this->removeButton->setSubmitValue((string) $this->position); + + $this->addHtml($this->removeButton); + } + } +} diff --git a/library/Notifications/Widget/ItemList/Escalations.php b/library/Notifications/Widget/ItemList/Escalations.php new file mode 100644 index 000000000..c24f7ded7 --- /dev/null +++ b/library/Notifications/Widget/ItemList/Escalations.php @@ -0,0 +1,32 @@ + 'escalations']; + + protected $tag = 'ul'; + + /** @var Escalation[] Escalation list items */ + protected $escalations; + + /** + * Create the escalations list + * + * @param Escalation[] $escalations + */ + public function __construct(array $escalations) + { + $this->escalations = $escalations; + } + + protected function assemble(): void + { + $this->addHtml(...$this->escalations); + } +} diff --git a/public/css/detail/event-rule-detail.less b/public/css/detail/event-rule-detail.less index 18ca41784..0e3c83e1b 100644 --- a/public/css/detail/event-rule-detail.less +++ b/public/css/detail/event-rule-detail.less @@ -1,204 +1,6 @@ .event-rule-detail { display: flex; align-items: baseline; - - > .right-arrow:first-child { - margin-top: 3.125em; - } - - &.invalid { - .escalations .escalation form.escalation-form { - select, - input { - &:invalid { - background-color: red; - } - } - } - } - - .search-controls { - display: inline-flex; - width: 20em; - min-width: unset; - padding: 0.5em; - border: 1px solid @gray-lighter; - border-radius: 0.5em; - - input.filter-input { - width: 20em; - background-color: @search-term-bg; - color: @search-term-color; - } - } - - .escalations { - display: inline-flex; - flex-direction: column; - width: 70em; - - .vertical-line { - position: absolute; - z-index: -1; - top: 15%; - bottom: 0; - margin-left: 1.25em; - } - - > .escalation { - display: flex; - align-items: center; - padding-bottom: 2em; - position: relative; - - &:before { - content: ""; - display: block; - position: absolute; - top: 0; - bottom: 0; - width: .5em; - margin-left: 1.25em; - background: @gray-lighter; - z-index: -1; - } - - &:first-child:before { - content: ""; - display: block; - top: calc(~"50% - 1em"); - } - - .right-arrow:first-child { - width: 2em; - } - - .right-arrow-long { - width: 38em; - } - - .right-arrow.right-arrow-long:first-child { - width: 47em; - } - - .right-arrow-one-escalation:first-child { - width: 15em; - } - - .escalation-condition-form, - .escalation-recipient-form { - width: 100%; - - padding: 0.5em; - border: 1px solid @gray-lighter; - border-radius: 0.5em; - - .options { - list-style-type: none; - padding: 0; - margin: 0; - - > li { - display: flex; - margin-bottom: .4em; - - &.option { - .errors { - display: inline-flex; - width: fit-content; - margin: 0; - } - - .errors + .remove-button { - margin: 0; - } - } - } - - .default-channel { - color: @disabled-gray; - } - - select, input { - min-width: 10em; - text-align: center; - height: 2.25em; - line-height: normal; - background: @search-term-bg; - color: @search-term-color; - } - - select { - background-image: url('@{iplWebAssets}/img/select-icon.svg'); - background-position: center right; - background-repeat: no-repeat; - } - - .left-operand { - border-radius: 0.4em 0 0 0.4em; - margin-right: 1px; - } - - .right-operand { - border-radius: 0 0.4em 0.4em 0; - width: 0; - flex: 1 1 auto; - margin-left: 1px; - } - - .operator-input { - min-width: unset; - padding-right: 0.5em; - width: 3em; - border-radius: unset; - margin: 0 1px; - background: @search-term-bg; - color: @search-term-color; - - option { text-align: center; } - } - } - - .remove-button { - height: 2.25em; - margin-left: 0.5em; - } - } - - .escalation-condition-form.count-zero-escalation-condition-form { - width: fit-content; - border: none; - margin: 0; - padding: 0; - - button[type="submit"] { - font-size: 2em; - width: 3em; - margin: 0; - background: @low-sat-blue; - border: none; - - &:hover { - background: @low-sat-blue-dark; - } - } - } - - .escalation-condition-form.count-zero-escalation-condition-form:after { - content: 'Condition'; - align-self: center; - margin-bottom: -1.5em; - color: @text-color-light; - } - } - } -} - -.escalations-with-add-form { - .add-escalation-form { - position: relative; - margin-left: -0.1em; - } } .cache-notice { @@ -209,224 +11,58 @@ .rounded-corners(); } -// Collecting button styles -.event-rule-button() { - color: @icinga-blue; - background: @low-sat-blue; - - border: none; - text-align: center; - line-height: 1.5; - display: block; - - &:hover, - &:focus { - color: @icinga-blue; - } - - &:hover { - background: @low-sat-blue-dark; - } - - &:focus { - outline: 3px solid fade(@icinga-blue, 50%); - outline-offset: 1px; - } +.new-event-rule { + margin-bottom: 1em; } -.escalation-form { - display: flex; - flex-direction: column; +.event-rule-form { + display: inline-flex; + width: fit-content; + max-width: unset; + align-items: flex-start; - .options +.add-button, - .remove-button { - .event-rule-button(); + > h2 { + margin: 0 0 0.5em 0; } - .options + .add-button { - margin-right: 3.5em; - } + .control-group { + display: inline-flex; + margin-right: 2em; - .options li { - input, select { - &:last-child:not(.remove-button) { - margin-right: 3.5em; - } + .control-label-group { + width: auto; } - } -} -.add-filter-form, -.escalation-form.count-zero-escalation-condition-form { - button[type="submit"] { - font-size: 2em; - height: 2.25em; - margin: 0; - - > .icon { - flex-wrap: wrap; - align-content: flex-start; + input[type='text'] { + max-width: unset; + width: 25em; } - - .event-rule-button(); - } -} - -.remove-escalation-form button[type="submit"], -.add-escalation-form button[type="submit"] { - .event-rule-button(); -} - -.right-arrow, -.horizontal-line { - display: inline-block; - background-color: @base-gray-lighter; - height: 0.5em; - text-align: end; -} - -.right-arrow { - width: 10em; - min-width: 2em; - margin-right: 0.4em; - position: relative; -} - -.horizontal-line { - width: 3em; - min-width: 1em; -} - -.right-arrow:after { - content: ''; - position: absolute; - border: 0.3em solid transparent; - border-left: 0.4em solid @base-gray-lighter; -} - -.vertical-line { - width: 0.7em; - background-color: @base-gray-lighter; -} - -.remove-escalation-form { - width: fit-content; -} - -#layout.minimal-layout form.icinga-form:not(.inline).remove-escalation-form:not(.inline), -#layout.twocols:not(.wide-layout) form.icinga-form.remove-escalation-form:not(.inline) { - width: fit-content; -} - -.add-escalation-form { - display: flex; - min-height: 6em; - align-items: center; - - &:before { - content: ""; - display: block; - position: absolute; - width: .5em; - background: @gray-lighter; - top: 0; - bottom: 50%; - left: calc(~"1.25em + 1px"); - z-index: -1; } } -#layout.minimal-layout form.icinga-form:not(.inline).count-zero-escalation-condition-form:not(.inline), -#layout.twocols:not(.wide-layout) form.icinga-form.escalation-condition-form.count-zero-escalation-condition-form:not(.inline) { +.save-config { + display: inline-flex; + float: right; width: fit-content; -} - -.add-filter-form { - text-align: center; - width: auto; - position: relative; - bottom: calc(~"-.5em - 1px"); -} + flex-direction: row-reverse; -.add-filter-form:after { - content: 'Filter'; - display: block; - color: @text-color-light; -} - -.horizontal-line-long { - width: 14.5em; -} - -.event-rule-and-save-forms { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - padding-bottom: 0.75em; - - .event-rule-form { - display: inline-flex; - width: fit-content; - max-width: unset; - - .control-group { - display: inline-flex; - margin-right: 2em; - - :last-child { - float: right; - } - - .control-label-group { - width: auto; - } + button[type="submit"] { + margin-right: 1em; - input[type='text'] { - max-width: unset; - width: 25em; - } + &.btn-remove:not([disabled]) { + .button(@body-bg-color, @color-critical, @color-critical-accentuated); + border: none; } - } - - .save-event-rule { - height: 2.25em; - display: inline-flex; - float: right; - margin: 1em 0 0 auto; - input[type="submit"]:not(:first-child) { - margin-left: 1em; - - &:disabled { - background: @gray-light; - color: @disabled-gray; - cursor: not-allowed; - border-color: transparent; - } - - &.btn-remove { - border: none; - - &:disabled { - background: none; - cursor: not-allowed; - opacity: 0.5; - } - } - - &.btn-discard-changes { - .event-rule-button(); - } + &.btn-discard-changes { + .event-rule-button(); } - } -} -.remove-escalation-form { - button[disabled] { &:disabled { background: @gray-light; color: @disabled-gray; cursor: not-allowed; + border: transparent; } } } diff --git a/public/css/event-rule-config.less b/public/css/event-rule-config.less new file mode 100644 index 000000000..502162990 --- /dev/null +++ b/public/css/event-rule-config.less @@ -0,0 +1,336 @@ +.event-rule-config { + display: flex; + align-items: center; + ul { + list-style-type: none; + margin: 0; + li { + display: inline-flex; + align-items: center; + } + } + + .escalations { + padding: 0; + position: relative; + + > .escalation:first-child:before { + content: ""; + display: block; + top: 2em; + } + + > .escalation:before { + content: ""; + display: block; + position: absolute; + top: 1.25em; + bottom: 0; + width: 0.5em; + margin-left: 1.25em; + background: var(--gray-lighter, #4b4b4b); + z-index: -1; + } + } + + .config-filter + .add-button { + align-self: flex-end; + } + + .filter-wrapper { + display: inline-flex; + align-self: flex-start; + } + + .filter-wrapper:has(.config-filter .search-controls) { + align-items: baseline; + } + + .add-escalation { + width: fit-content; + display: block; + } + + .right-arrow, + .horizontal-line { + display: inline-block; + background-color: @base-gray-lighter; + height: 0.5em; + text-align: end; + } + + .right-arrow { + width: 10em; + min-width: 2em; + margin-right: 0.4em; + position: relative; + } + + .horizontal-line { + width: 3em; + min-width: 1em; + } + + .right-arrow:after { + content: ''; + position: absolute; + border: 0.3em solid transparent; + border-left: 0.4em solid @base-gray-lighter; + } + + .vertical-line { + width: 0.7em; + background-color: @base-gray-lighter; + } + + .escalation { + margin-bottom: 2em; + .remove-button { + align-self: flex-start; + + &:disabled { + background: @gray-light; + color: @disabled-gray; + cursor: not-allowed; + border-color: transparent; + } + } + + .horizontal-line { + min-width: 3.5em; + } + + .right-arrow { + min-width: 10em; + } + + .zero-escalation-condition + .right-arrow, + .right-arrow:has(+ .zero-escalation-condition) { + min-width: calc(~"40% - 0.85em"); + } + + .remove-escalation { + margin-top: 1.25em; + } + } + + .config-filter { + align-self: flex-start; + padding-top: 0.5em; + } + + .search-controls { + display: inline-flex; + width: 20em; + min-width: unset; + padding: 0.5em; + border: 1px solid @gray-lighter; + border-radius: 0.5em; + + input.filter-input { + width: 20em; + background-color: @search-term-bg; + color: @search-term-color; + } + } +} + +.filter-wrapper, +.escalations { + .horizontal-line, + .right-arrow { + margin-top: 2em; + align-self: flex-start; + } +} + +.horizontal-line { + min-width: 10em; +} + +.remove-button, +.add-button { + .event-rule-button(); +} + +.escalation-condition, +.escalation-recipient { + width: 100%; + padding: 0.5em; + border: 1px solid @gray-lighter; + border-radius: 0.5em; + align-self: flex-start; + + .escalation-condition-list, + .escalation-recipient-list { + list-style-type: none; + padding: 0; + margin: 0; + + > li { + display: flex; + margin-bottom: .4em; + + &.escaltion-condition-list-item, + &.escaltion-recipient-list-item { + .errors { + display: inline-flex; + width: fit-content; + margin: 0; + } + + .errors + .remove-button { + margin: 0; + } + } + } + + .default-channel { + color: @disabled-gray; + } + + select, input { + min-width: 10em; + text-align: center; + height: 2.25em; + line-height: normal; + background: @search-term-bg; + color: @search-term-color; + } + + select { + background-image: url('@{iplWebAssets}/img/select-icon.svg'); + background-position: center right; + background-repeat: no-repeat; + } + + .left-operand { + border-radius: 0.4em 0 0 0.4em; + } + + .right-operand { + border-radius: 0 0.4em 0.4em 0; + width: 0; + flex: 1 1 auto; + margin-left: 1px; + } + + .operator-input { + min-width: unset; + padding-right: 0.5em; + width: 3em; + border-radius: unset; + margin: 0 1px; + background: @search-term-bg; + color: @search-term-color; + + .escaltion-condition-list-item, + .escaltion-recipient-list-item { + text-align: center; + } + } + } + + .remove-button { + height: 2.25em; + margin-left: 0.5em; + } + + input::-webkit-calendar-picker-indicator { + display: none; + } +} + +.escalation-recipient .left-operand { + margin-right: 1px; +} + +.escalation-condition, +.escalation-recipient { + .escalation-condition-list + .add-button, + .escalation-recipient-list + .add-button, + .remove-button { + .event-rule-button(); + } + + .escalation-condition-list + .add-button, + .escalation-recipient-list + .add-button { + width: calc(~"100% - 3.5em"); + } + + .escalation-condition-list li, + .escalation-recipient-list li { + input, select { + &:last-child:not(.remove-button) { + margin-right: 3.5em; + } + } + } +} + +.config-filter.empty-filter, +.escalation-condition.zero-escalation-condition { + border: 0; + padding: 0; + button[type="submit"] { + width: 100%; + font-size: 2em; + height: 2.25em; + margin: 0; + + > .icon { + flex-wrap: wrap; + align-content: flex-start; + } + + .event-rule-button(); + } +} + +.config-filter.empty-filter:after { + content: 'Filter'; + display: block; + text-align: center; + color: @text-color-light; +} + +.zero-escalation-condition:after { + content: 'Condition'; + display: block; + text-align: center; + color: @text-color-light; +} + +.event-rule-button() { + color: @icinga-blue; + background: @low-sat-blue; + + border: none; + text-align: center; + line-height: 1.5; + display: block; + + &:hover, + &:focus { + color: @icinga-blue; + } + + &:hover { + background: @low-sat-blue-dark; + } + + &:focus { + outline: 3px solid fade(@icinga-blue, 50%); + outline-offset: 1px; + } +} + +.submit-btn-duplicate { + border: 0; + height: 0; + margin: 0; + padding: 0; + visibility: hidden; + width: 0; + position: absolute; +} From 058641d8876b68d85e3c85cb35b6a0595e504a10 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 5 Aug 2025 17:20:42 +0200 Subject: [PATCH 2/6] event-rules: Enhance navigation flow --- .../controllers/EventRuleController.php | 69 ++++------ .../controllers/EventRulesController.php | 120 +++++------------- application/forms/EventRuleConfigForm.php | 4 +- public/css/common.less | 6 + 4 files changed, 68 insertions(+), 131 deletions(-) diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index 43700c865..70a234d26 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -41,18 +41,16 @@ public function init() public function indexAction(): void { - $this->sessionNamespace->delete('-1'); - $this->addTitleTab(t('Event Rule')); $this->controls->addAttributes(['class' => 'event-rule-detail']); - $ruleId = $this->params->getRequired('id'); + $ruleId = (int) $this->params->getRequired('id'); $configValues = $this->sessionNamespace->get($ruleId); $this->controls->addAttributes(['class' => 'event-rule-detail']); $disableSave = false; if ($configValues === null) { - $configValues = $this->fromDb((int) $ruleId); + $configValues = $this->fromDb($ruleId); $disableSave = true; } @@ -64,14 +62,15 @@ public function indexAction(): void $eventRuleConfig ->populate($configValues) ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { - $form->addOrUpdateRule((int) $ruleId, $configValues); + $insertId = $form->addOrUpdateRule($ruleId, $configValues); $this->sessionNamespace->delete($ruleId); Notification::success((sprintf(t('Successfully saved event rule %s'), $configValues['name']))); - $this->redirectNow(Links::eventRule((int) $ruleId)); + $this->sendExtraUpdates(['#col1']); + $this->redirectNow(Links::eventRule($insertId)); }) ->on(EventRuleConfigForm::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { if ($form->hasBeenRemoved()) { - $form->removeRule((int) $ruleId); + $form->removeRule($ruleId); $this->sessionNamespace->delete($ruleId); Notification::success(sprintf(t('Successfully deleted event rule %s'), $configValues['name'])); $this->redirectNow(Links::eventRules()); @@ -83,7 +82,12 @@ public function indexAction(): void $configValues['name'] ) ); - $this->redirectNow(Links::eventRule((int) $ruleId)); + + if ($ruleId === -1) { + $this->switchToSingleColumnLayout(); + } else { + $this->redirectNow(Links::eventRule($ruleId)); + } } }) ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { @@ -121,15 +125,18 @@ public function indexAction(): void ] ); - $deleteButton = new SubmitButtonElement( - 'delete', - [ - 'label' => t('Delete'), - 'form' => 'event-rule-config-form', - 'class' => 'btn-remove', - 'formnovalidate' => true - ] - ); + $deleteButton = null; + if ($ruleId !== -1) { + $deleteButton = new SubmitButtonElement( + 'delete', + [ + 'label' => t('Delete'), + 'form' => 'event-rule-config-form', + 'class' => 'btn-remove', + 'formnovalidate' => true + ] + ); + } $buttonsWrapper->add([$eventRuleConfigSubmitButton, $discardChangesButton, $deleteButton]); @@ -289,39 +296,19 @@ public static function createFilterString(Filter\Rule $filters): ?string public function editAction(): void { - /** @var string $ruleId */ - $ruleId = $this->params->getRequired('id'); - $config = $this->sessionNamespace->get($ruleId); - if ($config === null) { - if ($ruleId === '-1') { - $config = ['id' => $ruleId]; - } else { - $config = $this->fromDb((int) $ruleId); - } - } + $ruleId = (int) $this->params->getRequired('id'); + $config = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); $eventRuleForm = (new EventRuleForm()) ->populate($config) ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function ($form) use ($ruleId, $config) { $config['name'] = $form->getValue('name'); - if ($ruleId === '-1') { - $redirectUrl = Url::fromPath('notifications/event-rules/add', ['id' => '-1']); - } else { - $redirectUrl = Url::fromPath('notifications/event-rule', ['id' => $ruleId]); - $this->sendExtraUpdates(['#col1']); - } - $this->sessionNamespace->set($ruleId, $config); - $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); - $this->redirectNow($redirectUrl); + $this->closeModalAndRefreshRemainingViews(Links::eventRule($ruleId)); })->handleRequest($this->getServerRequest()); - if ($ruleId === '-1') { - $this->setTitle($this->translate('New Event Rule')); - } else { - $this->setTitle($this->translate('Edit Event Rule')); - } + $this->setTitle($this->translate('Edit Event Rule')); $this->addContent($eventRuleForm); } diff --git a/application/controllers/EventRulesController.php b/application/controllers/EventRulesController.php index ea91bb16c..1dba38004 100644 --- a/application/controllers/EventRulesController.php +++ b/application/controllers/EventRulesController.php @@ -6,18 +6,14 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; -use Icinga\Module\Notifications\Forms\EventRuleConfigForm; +use Icinga\Module\Notifications\Forms\EventRuleForm; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\View\EventRuleRenderer; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; -use Icinga\Web\Notification; use Icinga\Web\Session; -use ipl\Html\Attributes; use ipl\Html\Form; -use ipl\Html\FormElement\SubmitButtonElement; use ipl\Html\Html; -use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; @@ -27,18 +23,14 @@ use ipl\Web\Layout\DetailedItemLayout; use ipl\Web\Url; use ipl\Web\Widget\ButtonLink; -use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; class EventRulesController extends CompatController { use SearchControls; - /** @var Filter\Rule Filter from query string parameters */ - private $filter; - /** @var Session\SessionNamespace */ - private $sessionNamespace; + private Session\SessionNamespace $sessionNamespace; public function init() { @@ -49,7 +41,6 @@ public function init() public function indexAction(): void { $eventRules = Rule::on(Database::get()); - $this->sessionNamespace->delete('-1'); $limitControl = $this->createLimitControl(); $paginationControl = $this->createPaginationControl($eventRules); @@ -71,7 +62,7 @@ public function indexAction(): void if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { if ($searchBar->hasBeenSubmitted()) { - $filter = $this->getFilter(); + $filter = QueryString::parse((string) $this->params); } else { $this->addControl($searchBar); $this->sendMultipartUpdate(); @@ -87,14 +78,24 @@ public function indexAction(): void $this->addControl($sortControl); $this->addControl($limitControl); $this->addControl($searchBar); - $this->addContent( + + $addButton = (new ButtonLink( - t('New Event Rule'), - Url::fromPath('notifications/event-rule/edit', ['id' => -1]), + t('Add Event Rule'), + Url::fromPath('notifications/event-rules/add'), 'plus' - ))->openInModal() - ->addAttributes(['class' => 'add-new-component']) - ); + ))->openInModal(); + if (isset($this->sessionNamespace->{-1})) { + $this->addContent(Html::tag('div', ['class' => 'add-new-component'], [ + $addButton->disable($this->translate( + 'You have unsaved changes. Please save or discard them first.' + )), + (new Link($this->translate('Continue Editing'), Links::eventRule(-1))) + ->setBaseTarget('_next') + ])); + } else { + $this->addContent($addButton->addAttributes(['class' => 'add-new-component'])); + } $this->addContent( (new ObjectList($eventRules, new EventRuleRenderer())) @@ -111,62 +112,19 @@ public function indexAction(): void public function addAction(): void { - $this->addTitleTab(t('Add Event Rule')); - $this->getTabs()->setRefreshUrl(Url::fromPath('notifications/event-rules/add', ['id' => '-1'])); - - $this->controls->addAttributes(['class' => 'event-rule-detail']); - $ruleId = $this->params->get('id'); - $config = $this->sessionNamespace->get($ruleId); - $config['object_filter'] = $config['object_filter'] ?? null; - - $eventRuleConfigSubmitButton = (new SubmitButtonElement( - 'save', - [ - 'label' => t('Add Event Rule'), - 'form' => 'event-rule-config-form' - ] - ))->setWrapper(new HtmlElement('div', Attributes::create(['class' => ['icinga-controls', 'save-config']]))); - - $eventRuleConfig = new EventRuleConfigForm( - $config, - Url::fromPath( - 'notifications/event-rules/search-editor', - ['id' => $ruleId] - ) - ); - - $eventRuleConfig - ->populate($config) - ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($config) { - $ruleId = (int) $config['id']; - $ruleName = $config['name']; - $insertId = $form->addOrUpdateRule($ruleId, $config); - $this->sessionNamespace->delete($ruleId); - Notification::success(sprintf(t('Successfully add event rule %s'), $ruleName)); - $this->redirectNow(Links::eventRule($insertId)); - }) - ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($config) { - $formValues = $form->getValues(); - $config = array_merge($config, $formValues); - $config['rule_escalation'] = $formValues['rule_escalation']; - $this->sessionNamespace->set('-1', $config); - }) - ->handleRequest($this->getServerRequest()); - - $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $config['name'] ?? ''), - (new Link( - new Icon('edit'), - Url::fromPath('notifications/event-rule/edit', [ - 'id' => -1 - ]), - ['class' => 'control-button'] - ))->openInModal() - ]); - - $this->addControl($eventRuleForm); - $this->addControl($eventRuleConfigSubmitButton); - $this->addContent($eventRuleConfig); + $this->setTitle($this->translate('Add Event Rule')); + + $eventRuleForm = (new EventRuleForm()) + ->populate(['id' => -1]) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(Form::ON_SUCCESS, function ($form) { + $this->sessionNamespace->set(-1, ['id' => -1, 'name' => $form->getValue('name')]); + $this->sendExtraUpdates(['#col1']); + $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); + $this->redirectNow(Links::eventRule(-1)); + })->handleRequest($this->getServerRequest()); + + $this->addContent($eventRuleForm); } public function completeAction(): void @@ -191,20 +149,6 @@ public function searchEditorAction(): void $this->setTitle($this->translate('Adjust Filter')); } - /** - * Get the filter created from query string parameters - * - * @return Filter\Rule - */ - protected function getFilter(): Filter\Rule - { - if ($this->filter === null) { - $this->filter = QueryString::parse((string) $this->params); - } - - return $this->filter; - } - public function getTabs() { if ($this->getRequest()->getActionName() === 'index') { diff --git a/application/forms/EventRuleConfigForm.php b/application/forms/EventRuleConfigForm.php index 91d7d2087..f7f0f0376 100644 --- a/application/forms/EventRuleConfigForm.php +++ b/application/forms/EventRuleConfigForm.php @@ -142,7 +142,7 @@ protected function assemble(): void $this->getElement('zero-condition-escalation')->setValue($defaultEscalationPrefix); } - $configFilter = new EventRuleConfigFilter($this->searchEditorUrl, $this->config['object_filter']); + $configFilter = new EventRuleConfigFilter($this->searchEditorUrl, $this->config['object_filter'] ?? null); $this->registerElement($configFilter); $addEscalationButton = $this->createElement( @@ -428,7 +428,7 @@ public function addOrUpdateRule(int $id, array $config): int $db->commitTransaction(); - return (int) $id; + return $id; } /** diff --git a/public/css/common.less b/public/css/common.less index 8c50c1669..586b49349 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -80,6 +80,12 @@ .add-new-component { margin: 0 0 1em 1em; + + &:is(div) { + display: flex; + gap: .5em; + align-items: baseline; + } } .item-layout.rule footer { From 5acd6178a6c6b7fab25225434d9b204c32a24b96 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 6 Aug 2025 15:37:18 +0200 Subject: [PATCH 3/6] EventRuleController: Refine implementation --- .../controllers/EventRuleController.php | 124 ++++++++---------- public/css/detail/event-rule-detail.less | 13 +- 2 files changed, 60 insertions(+), 77 deletions(-) diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index 70a234d26..28c6c4304 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -23,6 +23,7 @@ use ipl\Web\Control\SearchEditor; use ipl\Web\Filter\QueryString; use ipl\Web\Url; +use ipl\Web\Widget\EmptyStateBar; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; @@ -31,7 +32,7 @@ class EventRuleController extends CompatController use Auth; /** @var Session\SessionNamespace */ - private $sessionNamespace; + private Session\SessionNamespace $sessionNamespace; public function init() { @@ -41,47 +42,50 @@ public function init() public function indexAction(): void { - $this->addTitleTab(t('Event Rule')); - $this->controls->addAttributes(['class' => 'event-rule-detail']); + $this->addTitleTab($this->translate('Event Rule')); + $this->content->addAttributes(['class' => 'event-rule-detail']); $ruleId = (int) $this->params->getRequired('id'); - $configValues = $this->sessionNamespace->get($ruleId); - $this->controls->addAttributes(['class' => 'event-rule-detail']); + $config = $this->sessionNamespace->get($ruleId); - $disableSave = false; - if ($configValues === null) { - $configValues = $this->fromDb($ruleId); - $disableSave = true; + $fromCache = true; + if ($config === null) { + $config = $this->fromDb($ruleId); + $fromCache = false; } $eventRuleConfig = new EventRuleConfigForm( - $configValues, + $config, Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]) ); $eventRuleConfig - ->populate($configValues) - ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { - $insertId = $form->addOrUpdateRule($ruleId, $configValues); + ->populate($config) + ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId, $config) { + $insertId = $form->addOrUpdateRule($ruleId, $config); $this->sessionNamespace->delete($ruleId); - Notification::success((sprintf(t('Successfully saved event rule %s'), $configValues['name']))); + Notification::success(sprintf( + $this->translate('Successfully saved event rule %s'), + $config['name'] + )); $this->sendExtraUpdates(['#col1']); $this->redirectNow(Links::eventRule($insertId)); }) - ->on(EventRuleConfigForm::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { + ->on(EventRuleConfigForm::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId, $config) { if ($form->hasBeenRemoved()) { $form->removeRule($ruleId); $this->sessionNamespace->delete($ruleId); - Notification::success(sprintf(t('Successfully deleted event rule %s'), $configValues['name'])); + Notification::success(sprintf( + $this->translate('Successfully deleted event rule %s'), + $config['name'] + )); $this->redirectNow(Links::eventRules()); } elseif ($form->hasBeenDiscarded()) { $this->sessionNamespace->delete($ruleId); - Notification::success( - sprintf( - t('Successfully discarded changes to event rule %s'), - $configValues['name'] - ) - ); + Notification::success(sprintf( + $this->translate('Successfully discarded changes to event rule %s'), + $config['name'] + )); if ($ruleId === -1) { $this->switchToSingleColumnLayout(); @@ -90,38 +94,35 @@ public function indexAction(): void } } }) - ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($ruleId, $configValues) { + ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($ruleId, $config) { $formValues = $form->getValues(); - $configValues = array_merge($configValues, $formValues); - $configValues['rule_escalation'] = $formValues['rule_escalation']; - $this->sessionNamespace->set($ruleId, $configValues); + $config = array_merge($config, $formValues); + $config['rule_escalation'] = $formValues['rule_escalation']; + $this->sessionNamespace->set($ruleId, $config); }) ->handleRequest($this->getServerRequest()); - $cache = $this->sessionNamespace->get($ruleId); $discardChangesButton = null; - if ($cache !== null) { - $this->addContent(Html::tag('div', ['class' => 'cache-notice'], t('There are unsaved changes.'))); + if ($fromCache) { + $this->addContent(new EmptyStateBar($this->translate('There are unsaved changes.'))); $discardChangesButton = new SubmitButtonElement( 'discard_changes', [ - 'label' => t('Discard Changes'), + 'label' => $this->translate('Discard Changes'), 'form' => 'event-rule-config-form', 'class' => 'btn-discard-changes', - 'formnovalidate' => true, + 'formnovalidate' => true ] ); - - $disableSave = false; } $buttonsWrapper = new HtmlElement('div', Attributes::create(['class' => ['icinga-controls', 'save-config']])); $eventRuleConfigSubmitButton = new SubmitButtonElement( 'save', [ - 'label' => t('Save'), + 'label' => $this->translate('Save'), 'form' => 'event-rule-config-form', - 'disabled' => $disableSave + 'disabled' => $fromCache ] ); @@ -130,7 +131,7 @@ public function indexAction(): void $deleteButton = new SubmitButtonElement( 'delete', [ - 'label' => t('Delete'), + 'label' => $this->translate('Delete'), 'form' => 'event-rule-config-form', 'class' => 'btn-remove', 'formnovalidate' => true @@ -141,7 +142,7 @@ public function indexAction(): void $buttonsWrapper->add([$eventRuleConfigSubmitButton, $discardChangesButton, $deleteButton]); $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $configValues['name']), + Html::tag('h2', $config['name']), (new Link( new Icon('edit'), Url::fromPath('notifications/event-rule/edit', [ @@ -171,38 +172,38 @@ public function fromDb(int $ruleId): array $rule = $query->first(); if ($rule === null) { - $this->httpNotFound(t('Rule not found')); + $this->httpNotFound($this->translate('Rule not found')); } $config = iterator_to_array($rule); $ruleEscalations = $rule ->rule_escalation - ->withoutColumns(['changed_at', 'deleted']); + ->columns(['id', 'position', 'condition']); foreach ($ruleEscalations as $re) { foreach ($re as $k => $v) { - if (in_array($k, ['id', 'condition'])) { + if ($k !== 'position') { $config[$re->getTableName()][$re->position][$k] = (string) $v; } } $escalationRecipients = $re ->rule_escalation_recipient - ->withoutColumns(['changed_at', 'deleted']); + ->columns(['id', 'position', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']); foreach ($escalationRecipients as $recipient) { - $requiredValues = []; + $recipientData = []; foreach ($recipient as $k => $v) { if ($v !== null && in_array($k, ['contact_id', 'contactgroup_id', 'schedule_id'])) { - $requiredValues[$k] = (string) $v; + $recipientData[$k] = (string) $v; } elseif (in_array($k, ['id', 'channel_id'])) { - $requiredValues[$k] = $v ? (string) $v : null; + $recipientData[$k] = $v ? (string) $v : null; } } - $config[$re->getTableName()][$re->position]['recipients'][] = $requiredValues; + $config[$re->getTableName()][$re->position]['recipients'][] = $recipientData; } } @@ -230,19 +231,12 @@ public function completeAction(): void */ public function searchEditorAction(): void { - /** @var string $ruleId */ - $ruleId = $this->params->shiftRequired('id'); - - $eventRule = $this->sessionNamespace->get($ruleId); - - if ($eventRule === null) { - $eventRule = $this->fromDb((int) $ruleId); - } + $ruleId = (int) $this->params->shiftRequired('id'); + $config = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); $editor = new SearchEditor(); - $objectFilter = $eventRule['object_filter'] ?? ''; - $editor->setQueryString($objectFilter) + $editor->setQueryString($config['object_filter'] ?? '') ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->setSuggestionUrl( Url::fromPath('notifications/event-rule/complete', [ @@ -252,17 +246,13 @@ public function searchEditorAction(): void ]) ); - $editor->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) use ($ruleId, $eventRule) { - $eventRule['object_filter'] = self::createFilterString($form->getFilter()); - $this->sessionNamespace->set($ruleId, $eventRule); - $this->getResponse() - ->setHeader('X-Icinga-Container', '_self') - ->redirectAndExit( - Url::fromPath( - 'notifications/event-rule', - ['id' => $ruleId] - ) - ); + $editor->on(Form::ON_SUCCESS, function (SearchEditor $form) use ($ruleId, $config) { + $config['object_filter'] = $this->createFilterString($form->getFilter()); + $this->sessionNamespace->set($ruleId, $config); + $this->closeModalAndRefreshRelatedView(Url::fromPath( + 'notifications/event-rule', + ['id' => $ruleId] + )); }); $editor->handleRequest($this->getServerRequest()); @@ -278,7 +268,7 @@ public function searchEditorAction(): void * * @return ?string */ - public static function createFilterString(Filter\Rule $filters): ?string + private function createFilterString(Filter\Rule $filters): ?string { if ($filters instanceof Filter\Chain) { foreach ($filters as $filter) { diff --git a/public/css/detail/event-rule-detail.less b/public/css/detail/event-rule-detail.less index 0e3c83e1b..541fce33c 100644 --- a/public/css/detail/event-rule-detail.less +++ b/public/css/detail/event-rule-detail.less @@ -1,14 +1,7 @@ .event-rule-detail { - display: flex; - align-items: baseline; -} - -.cache-notice { - margin: 1em; - padding: 1em; - background-color: @gray-lighter; - text-align: center; - .rounded-corners(); + .empty-state-bar { + margin-bottom: 1em; + } } .new-event-rule { From 47bccc019e6580f02fe65444277fd8ac8fa73ccc Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 13 Aug 2025 16:13:39 +0200 Subject: [PATCH 4/6] Drop unused files --- .../EscalationCondition.php | 298 ------------------ .../EscalationRecipient.php | 277 ---------------- .../Widget/ItemList/Escalation.php | 94 ------ .../ItemList/EscalationConditionList.php | 47 --- .../ItemList/EscalationConditionListItem.php | 105 ------ .../ItemList/EscalationRecipientList.php | 49 --- .../ItemList/EscalationRecipientListItem.php | 100 ------ .../Widget/ItemList/Escalations.php | 32 -- 8 files changed, 1002 deletions(-) delete mode 100644 application/forms/EventRuleConfigElements/EscalationCondition.php delete mode 100644 application/forms/EventRuleConfigElements/EscalationRecipient.php delete mode 100644 library/Notifications/Widget/ItemList/Escalation.php delete mode 100644 library/Notifications/Widget/ItemList/EscalationConditionList.php delete mode 100644 library/Notifications/Widget/ItemList/EscalationConditionListItem.php delete mode 100644 library/Notifications/Widget/ItemList/EscalationRecipientList.php delete mode 100644 library/Notifications/Widget/ItemList/EscalationRecipientListItem.php delete mode 100644 library/Notifications/Widget/ItemList/Escalations.php diff --git a/application/forms/EventRuleConfigElements/EscalationCondition.php b/application/forms/EventRuleConfigElements/EscalationCondition.php deleted file mode 100644 index 2eec71e7e..000000000 --- a/application/forms/EventRuleConfigElements/EscalationCondition.php +++ /dev/null @@ -1,298 +0,0 @@ - 'escalation-condition']; - - /** @var EscalationConditionListItem[] Condition list items */ - protected $conditionListItems = []; - - /** @var EventRuleConfigForm */ - protected $configForm; - - /** @var string */ - protected $prefix; - - /** @var string condition filter */ - protected $condition; - - public function __construct(string $prefix, EventRuleConfigForm $configForm) - { - $this->prefix = $prefix; - $this->configForm = $configForm; - - parent::__construct('escalation-condition_' . $this->prefix); - } - - /** - * Set the condition value - * - * @param $id - * @param $condition - * - * @return $this - */ - public function setCondition($condition): self - { - $this->condition = $condition; - - return $this; - } - - /** - * Get the rendered condition - * - * @return string - */ - public function getCondition(): string - { - return $this->condition; - } - - protected function assemble(): void - { - $filters = QueryString::parse($this->condition); - - if ($filters instanceof Filter\Chain) { - $conditionCount = $filters->count(); - } else { - $conditionCount = 1; - - $filters = Filter::all($filters); - } - - $this->addElement('hidden', 'condition-count', ['value' => $conditionCount]); - // Escalation Id to which the condition belongs - $this->addElement('hidden', 'id'); - - /** @var SubmitButtonElement $addCondition */ - $addCondition = $this->createElement( - 'submitButton', - 'add-condition', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'title' => $this->translate('Add Condition'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($addCondition); - - $zeroConditions = (string) $this->configForm->getValue('zero-condition-escalation') === $this->prefix; - $configHasZeroConditionEscalation = $this->configForm->hasZeroConditionEscalation(); - if ($zeroConditions && $configHasZeroConditionEscalation) { - $conditionCount = 0; - } elseif ($conditionCount === 0) { - $filters->add(Filter::equal('placeholder', '')); - - $conditionCount += 1; - } - - if ($addCondition->hasBeenPressed()) { - $filters->add(Filter::equal('placeholder', '')); - $conditionCount += 1; - $this->getElement('condition-count')->setValue($conditionCount); - } - - if ($conditionCount === 0) { - $this->addAttributes(['class' => 'zero-escalation-condition']); - $this->addElement($addCondition); - - return; - } - - $this->getAttributes()->remove('class', 'zero-escalation-condition'); - $removePosition = null; - - $position = 1; - $operators = ['=', '>', '>=', '<', '<=', '!=']; - $severityOptions = [ - 'ok' => $this->translate('Ok', 'notification.severity'), - 'debug' => $this->translate('Debug', 'notification.severity'), - 'info' => $this->translate('Information', 'notification.severity'), - 'notice' => $this->translate('Notice', 'notification.severity'), - 'warning' => $this->translate('Warning', 'notification.severity'), - 'err' => $this->translate('Error', 'notification.severity'), - 'crit' => $this->translate('Critical', 'notification.severity'), - 'alert' => $this->translate('Alert', 'notification.severity'), - 'emerg' => $this->translate('Emergency', 'notification.severity') - ]; - - /** @var Filter\Condition $filter */ - foreach ($filters as $filter) { - $filterType = $this->getPopulatedValue('column_' . $position) ?? $filter->getColumn(); - if ($filterType === 'placeholder') { - $filterType = ''; - } - - $typeElement = $this->createElement( - 'select', - 'column_' . $position, - [ - 'class' => ['autosubmit', 'left-operand'], - 'options' => [ - '' => sprintf(' - %s - ', $this->translate('Please choose')), - 'incident_severity' => $this->translate('Incident Severity'), - 'incident_age' => $this->translate('Incident Age') - ], - 'disabledOptions' => [''], - 'required' => true, - 'value' => $filterType, - ] - ); - - $operatorElement = $this->createElement( - 'select', - 'operator_' . $position, - [ - 'class' => ['operator-input', 'autosubmit'], - 'options' => array_combine($operators, $operators), - 'required' => true, - 'value' => QueryString::getRuleSymbol($filter), - ] - ); - - $valName = 'val_' . $position; - $filterValue = $filter->getValue(); - switch ($filterType) { - case 'incident_severity': - $valElement = $this->createElement( - 'select', - $valName, - [ - 'class' => ['autosubmit', 'right-operand'], - 'options' => $severityOptions, - 'value' => $filterValue - ] - ); - - break; - case 'incident_age': - if (array_key_exists($filterValue, $severityOptions)) { - $filterValue = ''; - $this->clearPopulatedValue($valName); - } - - $valElement = $this->createElement( - 'text', - $valName, - [ - 'required' => true, - 'class' => ['autosubmit', 'right-operand'], - 'value' => $filterValue, - 'validators' => [ - new CallbackValidator(function ($value, $validator) { - if (! preg_match('~^\d+(?:\.?\d*)?[hms]{1}$~', $value)) { - $validator->addMessage( - $this->translate( - 'Only numbers with optional fractions (separated by a dot)' - . ' and one of these suffixes are allowed: h, m, s' - ) - ); - - return false; - } - - $validator->clearMessages(); - - return true; - }) - ] - ] - ); - - break; - default: - $valElement = $this->createElement('text', $valName, [ - 'class' => 'right-operand', - 'placeholder' => $this->translate('Please make a decision'), - 'disabled' => true - ]); - } - - $this->registerElement($typeElement); - $this->registerElement($operatorElement); - $this->registerElement($valElement); - - $removeButton = null; - - if (($conditionCount > 1) || ($conditionCount === 1 && ! $configHasZeroConditionEscalation)) { - $removeButton = $this->createRemoveButton($position); - if ($removeButton->hasBeenPressed()) { - $removePosition = $position; - } - } - - (new EventRuleDecorator())->decorate($valElement); - $this->conditionListItems[$position] = new EscalationConditionListItem( - $position, - $typeElement, - $operatorElement, - $valElement, - $removeButton - ); - - $position++; - } - - if ($removePosition) { - $this->getElement('condition-count')->setValue(--$conditionCount); - if ($conditionCount === 1 && $configHasZeroConditionEscalation) { - $idx = $removePosition === 1 ? 2 : 1; - $this->conditionListItems[$idx]->removeRemoveButton(); - $filters->getIterator()->offsetUnset($idx); - } - } - - $this->condition = (new FilterRenderer($filters))->render(); - $this->add(new EscalationConditionList($this->conditionListItems)); - $this->addElement($addCondition); - } - - /** - * Create remove button for the condition in the given position - * - * @param int $count - * - * @return SubmitButtonElement - */ - protected function createRemoveButton(int $count): SubmitButtonElement - { - $removeButton = new SubmitButtonElement( - 'remove', - [ - 'class' => ['remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus'), - 'title' => $this->translate('Remove'), - 'formnovalidate' => true, - 'value' => (string) $count - ] - ); - - $this->registerElement($removeButton); - - return $removeButton; - } - - public function hasValue(): bool - { - $this->ensureAssembled(); - - return parent::hasValue(); - } -} diff --git a/application/forms/EventRuleConfigElements/EscalationRecipient.php b/application/forms/EventRuleConfigElements/EscalationRecipient.php deleted file mode 100644 index 1cfcb07ee..000000000 --- a/application/forms/EventRuleConfigElements/EscalationRecipient.php +++ /dev/null @@ -1,277 +0,0 @@ - 'escalation-recipient']; - - /** @var EscalationRecipientListItem[] */ - protected $recipientListItems = []; - - /** @var array */ - protected $recipients = []; - - public function __construct($name) - { - parent::__construct('escalation-recipient_' . $name); - } - - protected function assemble(): void - { - $recipientCount = count($this->recipients); - $this->addElement('hidden', 'recipient-count', ['value' => $recipientCount]); - - $addRecipientButton = $this->createElement( - 'submitButton', - 'add-recipient', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'title' => $this->translate('Add Recipient'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($addRecipientButton); - - if ($addRecipientButton->hasBeenPressed()) { - $this->getElement('recipient-count')->setValue(++$recipientCount); - $this->recipients[$recipientCount] = []; - } - - $defaultOption = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $recipientOptions = $defaultOption + $this->fetchOptions(); - $channelOptions = $defaultOption + Channel::fetchChannelNames(Database::get()); - $removePosition = null; - - $position = 1; - foreach ($this->recipients as $escalationRecipient) { - $this->addElement( - 'hidden', - 'id_' . $position, - ['value' => $escalationRecipient['id'] ?? null] - ); - - $recipient = array_filter($escalationRecipient, function ($k) { - return in_array($k, ['contact_id', 'contactgroup_id', 'schedule_id']); - }, ARRAY_FILTER_USE_KEY); - - if (empty($recipient)) { - $recipientVal = $this->getPopulatedValue('column_' . $position, ''); - } else { - // Trim the trailing '_id' from the array key - $recipientType = substr(array_key_first($recipient) ?? '', 0, -3); - $recipientVal = $recipientType . '_' . array_shift($recipient); - } - - $col = $this->createElement( - 'select', - 'column_' . $position, - [ - 'class' => ['autosubmit', 'left-operand'], - 'options' => $recipientOptions, - 'disabledOptions' => [''], - 'required' => true, - 'value' => $recipientVal - ] - ); - - $this->registerElement($col); - - if (isset($escalationRecipient['channel_id'])) { - $channelId = (int) $escalationRecipient['channel_id']; - } else { - $channelId = ''; - } - - $val = $this->createElement( - 'select', - 'val_' . $position, - [ - 'class' => ['autosubmit', 'right-operand'], - 'options' => $channelOptions, - 'disabledOptions' => [''], - 'value' => $this->getPopulatedValue('val_' . $position) ?? $channelId - ] - ); - - $recipientVal = $this->getValue('column_' . $position); - if ($recipientVal !== null) { - $recipientType = explode('_', $recipientVal)[0]; - if ($recipientType === 'contact') { - $val->setOptions(['' => $this->translate('Default Channel')] + $channelOptions); - $val->setDisabledOptions([]); - - if ($this->getPopulatedValue('val_' . $position, '') === '') { - $val->addAttributes(['class' => 'default-channel']); - } - } else { - $val->addAttributes(['required' => true]); - } - } else { - $val = $this->createElement('text', 'val_' . $position, [ - 'class' => 'right-operand', - 'placeholder' => $this->translate('Please make a decision'), - 'disabled' => true, - 'value' => $this->getPopulatedValue('val_' . $position) - ]); - } - - $this->registerElement($val); - $removeButton = null; - if ($recipientCount > 1) { - $removeButton = $this->createRemoveButton($position); - if ($removeButton->hasBeenPressed()) { - $removePosition = $position; - } - } - - $this->recipientListItems[$position] = new EscalationRecipientListItem( - $position++, - $col, - $val, - $removeButton - ); - } - - if ($removePosition) { - $recipientCount -= 1; - $this->getElement('recipient-count')->setValue($recipientCount); - if ($recipientCount === 1) { - $idx = $removePosition === 1 ? 2 : 1; - $this->recipientListItems[$idx]->removeRemoveButton(); - } - } - - $this->add(new EscalationRecipientList($this->recipientListItems)); - - $this->addElement($addRecipientButton); - } - - /** - * Fetch recipient options - * - * @return array> - */ - protected function fetchOptions(): array - { - $options = []; - /** @var Contact $contact */ - foreach (Contact::on(Database::get()) as $contact) { - $options[$this->translate('Contacts')]['contact_' . $contact->id] = $contact->full_name; - } - - /** @var Contactgroup $contactgroup */ - foreach (Contactgroup::on(Database::get()) as $contactgroup) { - $options[$this->translate('Contact Groups')]['contactgroup_' . $contactgroup->id] = $contactgroup->name; - } - - /** @var Schedule $schedule */ - foreach (Schedule::on(Database::get()) as $schedule) { - $options[$this->translate('Schedules')]['schedule_' . $schedule->id] = $schedule->name; - } - - return $options; - } - - /** - * Create remove button for the recipient in the given position - * - * @param int $pos - * - * @return SubmitButtonElement - */ - protected function createRemoveButton(int $pos): SubmitButtonElement - { - $removeButton = new SubmitButtonElement( - 'remove', - [ - 'class' => ['remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus'), - 'title' => $this->translate('Remove'), - 'formnovalidate' => true, - 'value' => (string) $pos - ] - ); - - $this->registerElement($removeButton); - - return $removeButton; - } - - public function hasValue(): bool - { - $this->ensureAssembled(); - - return parent::hasValue(); - } - - public function setRecipients(array $recipients): self - { - $this->recipients = $recipients; - - if (empty($this->recipients)) { - $this->recipients = [0 => []]; - } - - return $this; - } - - /** - * Get recipients of the escalation - * - * @return array> - */ - public function getRecipients(): array - { - /** @var int $count */ - $count = $this->getValue('recipient-count'); - $removePosition = $this->getValue('remove'); - if ($removePosition) { - // This is needed as the count is already reduced when the remove button of a recipient is clicked, but the - // registered element is not yet removed from the form. Hence, needs to be skipped in the loop when fetching - // the recipients - $count += 1; - } - - $values = []; - for ($i = 1; $i <= $count; $i++) { - if ($i === (int) $removePosition) { - continue; - } - - $value = []; - $value['channel_id'] = $this->getValue('val_' . $i); - $value['id'] = $this->getValue('id_' . $i); - - /** @var ?string $recipient */ - $recipient = $this->getValue('column_' . $i); - - if ($recipient === null) { - $values[] = $value; - - continue; - } - - [$recipientType, $id] = explode('_', $recipient, 2); - - $value[$recipientType . '_id'] = $id; - - $values[] = $value; - } - - return $values; - } -} diff --git a/library/Notifications/Widget/ItemList/Escalation.php b/library/Notifications/Widget/ItemList/Escalation.php deleted file mode 100644 index e1fc676b0..000000000 --- a/library/Notifications/Widget/ItemList/Escalation.php +++ /dev/null @@ -1,94 +0,0 @@ - 'escalation']; - - protected $tag = 'li'; - - /** @var ?SubmitButtonElement Remove button of the escalation */ - protected $removeButton; - - /** @var EscalationCondition Escalation condition fieldset */ - protected $condition; - - /** @var EscalationRecipient Escalation recipient fieldset */ - protected $recipient; - - /** - * Create the escalation list item - * - * @param EscalationCondition $condition - * @param EscalationRecipient $recipient - * @param ?SubmitButtonElement $removeButton - */ - public function __construct( - EscalationCondition $condition, - EscalationRecipient $recipient, - ?SubmitButtonElement $removeButton - ) { - $this->condition = $condition; - $this->recipient = $recipient; - $this->removeButton = $removeButton; - } - - /** - * Check if the add button of the condition fieldset has been pressed - * - * @return bool - */ - public function addConditionHasBeenPressed(): bool - { - return $this->condition->getPopulatedValue('add-condition') === 'y'; - } - - /** - * Check if the last condition of the escalation has been removed - * - * @return bool - */ - public function lastConditionHasBeenRemoved(): bool - { - return $this->condition->getPopulatedValue('condition-count') === '1' - && $this->condition->getPopulatedValue('remove') === '1'; - } - - /** - * Create first component of the escalation widget - * - * @return FlowLine|SubmitButtonElement - */ - protected function createFirstComponent() - { - if ($this->removeButton === null) { - return (new FlowLine())->getHorizontalLine(); - } - - return $this->removeButton; - } - - protected function assemble(): void - { - $firstComponent = $this->createFirstComponent(); - if ($firstComponent) { - $this->addHtml($firstComponent); - } - - $this->addHtml( - (new FlowLine())->getRightArrow(), - $this->condition, - (new FlowLine())->getRightArrow(), - $this->recipient - ); - } -} diff --git a/library/Notifications/Widget/ItemList/EscalationConditionList.php b/library/Notifications/Widget/ItemList/EscalationConditionList.php deleted file mode 100644 index f19b51687..000000000 --- a/library/Notifications/Widget/ItemList/EscalationConditionList.php +++ /dev/null @@ -1,47 +0,0 @@ - 'escalation-condition-list']; - - protected $tag = 'ul'; - - /** @var EscalationConditionListItem[] Condition list items */ - protected $conditions; - - /** - * Create a list of escalation conditions - * - * @param EscalationConditionListItem[] $conditions - */ - public function __construct(array $conditions) - { - $this->conditions = $conditions; - } - - protected function assemble(): void - { - $removedPosition = null; - foreach ($this->conditions as $position => $condition) { - if ($condition->hasBeenRemoved()) { - $removedPosition = $position; - - continue; - } - - if ($removedPosition !== null) { - $condition->setPosition($position - 1); - } - - if ($position !== $removedPosition) { - $this->addHtml($condition); - } - } - } -} diff --git a/library/Notifications/Widget/ItemList/EscalationConditionListItem.php b/library/Notifications/Widget/ItemList/EscalationConditionListItem.php deleted file mode 100644 index 3205afc97..000000000 --- a/library/Notifications/Widget/ItemList/EscalationConditionListItem.php +++ /dev/null @@ -1,105 +0,0 @@ - 'escalation-condition-list-item']; - - protected $tag = 'li'; - - /** @var ?SubmitButtonElement Remove button for the recipient */ - protected $removeButton; - - /** @var FormElement Condition type */ - protected $conditionType; - - /** @var FormElement Operator used for the condition */ - protected $operator; - - /** @var FormElement Condition value */ - protected $conditionVal; - - /** @var int Position of the condition in the condition list */ - protected $position; - - /** - * Create the condition list item of the escalation - * - * @param FormElement $conditionType - * @param FormElement $operator - * @param FormElement $conditionVal - * @param ?SubmitButtonElement $removeButton - */ - public function __construct( - int $position, - FormElement $conditionType, - FormElement $operator, - FormElement $conditionVal, - ?SubmitButtonElement $removeButton - ) { - $this->position = $position; - $this->conditionType = $conditionType; - $this->operator = $operator; - $this->conditionVal = $conditionVal; - $this->removeButton = $removeButton; - } - - /** - * Return whether the condition has been removed - * - * @return bool - */ - public function hasBeenRemoved(): bool - { - return $this->removeButton && $this->removeButton->hasBeenPressed(); - } - - /** - * Set the position of the condition list item - * - * @param int $position - * - * @return $this - */ - public function setPosition(int $position): self - { - $this->position = $position; - - return $this; - } - - /** - * Removes the remove button from the list item - * - * @return $this - */ - public function removeRemoveButton(): self - { - $this->removeButton = null; - - return $this; - } - - protected function assemble(): void - { - $this->conditionType->setAttribute('name', 'column_' . $this->position); - $this->operator->setAttribute('name', 'operator_' . $this->position); - $this->conditionVal->setAttribute('name', 'val_' . $this->position); - - $this->addHtml($this->conditionType, $this->operator, $this->conditionVal); - if ($this->removeButton) { - $this->removeButton->setSubmitValue((string) $this->position); - $this->addHtml($this->removeButton); - } - } -} diff --git a/library/Notifications/Widget/ItemList/EscalationRecipientList.php b/library/Notifications/Widget/ItemList/EscalationRecipientList.php deleted file mode 100644 index d61f89834..000000000 --- a/library/Notifications/Widget/ItemList/EscalationRecipientList.php +++ /dev/null @@ -1,49 +0,0 @@ - 'escalation-recipient-list']; - - protected $tag = 'ul'; - - /** @var EscalationRecipientListItem[] Recipient list items of the escalation */ - protected $recipients; - - /** - * Create a list of escalation recipients - * - * @param EscalationRecipientListItem[] $recipients - */ - public function __construct(array $recipients) - { - $this->recipients = $recipients; - } - - protected function assemble(): void - { - $removedPosition = null; - $recipientCount = count($this->recipients); - foreach ($this->recipients as $position => $recipient) { - if ($recipient->hasBeenRemoved()) { - $removedPosition = $position; - --$recipientCount; - - continue; - } - - if ($removedPosition !== null) { - $recipient->setPosition($position - 1); - } - - if ($position !== $removedPosition) { - $this->addHtml($recipient); - } - } - } -} diff --git a/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php b/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php deleted file mode 100644 index 6d6e5a529..000000000 --- a/library/Notifications/Widget/ItemList/EscalationRecipientListItem.php +++ /dev/null @@ -1,100 +0,0 @@ - 'escalation-recipient-list-item']; - - protected $tag = 'li'; - - /** @var ?SubmitButtonElement Remove button for the recipient */ - protected $removeButton; - - /** @var FormElement Recipient name */ - protected $recipient; - - /** @var FormElement Recipient channel */ - protected $channel; - - /** @var int Position of the recipient in the recipient list */ - protected $position; - - /** - * Create the recipient list item of the escalation - * - * @param int $position - * @param FormElement $recipient - * @param FormElement $channel - * @param ?SubmitButtonElement $removeButton - */ - public function __construct( - int $position, - FormElement $recipient, - FormElement $channel, - ?SubmitButtonElement $removeButton - ) { - $this->position = $position; - $this->recipient = $recipient; - $this->channel = $channel; - $this->removeButton = $removeButton; - } - - /** - * Return whether the condition has been removed - * - * @return bool - */ - public function hasBeenRemoved(): bool - { - return $this->removeButton && $this->removeButton->hasBeenPressed(); - } - - /** - * Set the position of the condition list item - * - * @param int $position - * - * @return $this - */ - public function setPosition(int $position): self - { - $this->position = $position; - - return $this; - } - - /** - * Removes the remove button from the list item - * - * @return $this - */ - public function removeRemoveButton(): self - { - $this->removeButton = null; - - return $this; - } - - protected function assemble(): void - { - $this->recipient->setAttribute('name', 'column_' . $this->position); - $this->channel->setAttribute('name', 'val_' . $this->position); - - $this->addHtml($this->recipient, $this->channel); - if ($this->removeButton) { - $this->removeButton->setSubmitValue((string) $this->position); - - $this->addHtml($this->removeButton); - } - } -} diff --git a/library/Notifications/Widget/ItemList/Escalations.php b/library/Notifications/Widget/ItemList/Escalations.php deleted file mode 100644 index c24f7ded7..000000000 --- a/library/Notifications/Widget/ItemList/Escalations.php +++ /dev/null @@ -1,32 +0,0 @@ - 'escalations']; - - protected $tag = 'ul'; - - /** @var Escalation[] Escalation list items */ - protected $escalations; - - /** - * Create the escalations list - * - * @param Escalation[] $escalations - */ - public function __construct(array $escalations) - { - $this->escalations = $escalations; - } - - protected function assemble(): void - { - $this->addHtml(...$this->escalations); - } -} From fdc44a0b7d2c3f8b0f2bf384b23695ac50c5bb33 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 12 Aug 2025 11:34:32 +0200 Subject: [PATCH 5/6] Write event rule form from scratch again --- .../controllers/EventRuleController.php | 308 +++---- .../controllers/EventRulesController.php | 36 +- .../ConfigProvider.php | 35 + .../ConfigProviderInterface.php | 41 + .../DynamicElements.php | 119 +++ .../EventRuleConfigElements/Escalation.php | 173 ++++ .../EscalationCondition.php | 188 ++++ .../EscalationConditions.php | 93 ++ .../EscalationRecipient.php | 202 +++++ .../EscalationRecipients.php | 82 ++ .../EventRuleConfigElements/Escalations.php | 82 ++ .../EventRuleConfigFilter.php | 95 -- .../NotificationConfigProvider.php | 67 ++ application/forms/EventRuleConfigForm.php | 666 ++++++-------- application/forms/EventRuleForm.php | 3 +- library/Notifications/Widget/FlowLine.php | 34 - library/Notifications/Widget/RightArrow.php | 15 - phpstan-baseline-7x.neon | 11 - phpstan-baseline-8x.neon | 11 - phpstan-baseline-by-php-version.php | 12 - phpstan-baseline-standard.neon | 250 +----- phpstan.neon | 1 - .../forms/EventRuleConfigFormTest.php | 823 ++++++++++++++++++ 23 files changed, 2295 insertions(+), 1052 deletions(-) create mode 100644 application/forms/EventRuleConfigElements/ConfigProvider.php create mode 100644 application/forms/EventRuleConfigElements/ConfigProviderInterface.php create mode 100644 application/forms/EventRuleConfigElements/DynamicElements.php create mode 100644 application/forms/EventRuleConfigElements/Escalation.php create mode 100644 application/forms/EventRuleConfigElements/EscalationCondition.php create mode 100644 application/forms/EventRuleConfigElements/EscalationConditions.php create mode 100644 application/forms/EventRuleConfigElements/EscalationRecipient.php create mode 100644 application/forms/EventRuleConfigElements/EscalationRecipients.php create mode 100644 application/forms/EventRuleConfigElements/Escalations.php delete mode 100644 application/forms/EventRuleConfigElements/EventRuleConfigFilter.php create mode 100644 application/forms/EventRuleConfigElements/NotificationConfigProvider.php delete mode 100644 library/Notifications/Widget/FlowLine.php delete mode 100644 library/Notifications/Widget/RightArrow.php delete mode 100644 phpstan-baseline-7x.neon delete mode 100644 phpstan-baseline-8x.neon delete mode 100644 phpstan-baseline-by-php-version.php create mode 100644 test/php/application/forms/EventRuleConfigFormTest.php diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index 28c6c4304..bc03fd300 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -4,210 +4,149 @@ namespace Icinga\Module\Notifications\Controllers; +use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Common\Auth; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; +use Icinga\Module\Notifications\Forms\EventRuleConfigElements\NotificationConfigProvider; use Icinga\Module\Notifications\Forms\EventRuleConfigForm; use Icinga\Module\Notifications\Forms\EventRuleForm; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Web\Control\SearchBar\ExtraTagSuggestions; use Icinga\Web\Notification; use Icinga\Web\Session; -use ipl\Html\Attributes; use ipl\Html\Form; -use ipl\Html\FormElement\SubmitButtonElement; use ipl\Html\Html; -use ipl\Html\HtmlElement; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Control\SearchEditor; -use ipl\Web\Filter\QueryString; +use ipl\Web\Filter\Renderer; use ipl\Web\Url; -use ipl\Web\Widget\EmptyStateBar; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; +use Psr\Http\Message\ServerRequestInterface; class EventRuleController extends CompatController { use Auth; /** @var Session\SessionNamespace */ - private Session\SessionNamespace $sessionNamespace; + private Session\SessionNamespace $session; - public function init() + public function init(): void { - $this->sessionNamespace = Session::getSession()->getNamespace('notifications'); $this->assertPermission('notifications/config/event-rule'); + $this->session = Session::getSession()->getNamespace('notifications.event-rule'); } public function indexAction(): void { $this->addTitleTab($this->translate('Event Rule')); $this->content->addAttributes(['class' => 'event-rule-detail']); + $this->getTabs()->disableLegacyExtensions(); $ruleId = (int) $this->params->getRequired('id'); - $config = $this->sessionNamespace->get($ruleId); - $fromCache = true; - if ($config === null) { - $config = $this->fromDb($ruleId); - $fromCache = false; - } - - $eventRuleConfig = new EventRuleConfigForm( - $config, + $multiPartUpdate = false; + $eventRuleConfig = (new EventRuleConfigForm( + new NotificationConfigProvider(), Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]) - ); + ))->setCsrfCounterMeasureId(Session::getSession()->getId()); $eventRuleConfig - ->populate($config) - ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId, $config) { - $insertId = $form->addOrUpdateRule($ruleId, $config); - $this->sessionNamespace->delete($ruleId); + ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId) { + if ($ruleId !== -1) { + $rule = $this->fetchRule($ruleId); + } else { + $rule = null; + } + + $ruleId = $form->storeInDatabase(Database::get(), $rule); Notification::success(sprintf( $this->translate('Successfully saved event rule %s'), - $config['name'] + $form->getValue('name') )); $this->sendExtraUpdates(['#col1']); - $this->redirectNow(Links::eventRule($insertId)); + $this->redirectNow(Links::eventRule($ruleId)); }) - ->on(EventRuleConfigForm::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId, $config) { + ->on(Form::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId) { if ($form->hasBeenRemoved()) { - $form->removeRule($ruleId); - $this->sessionNamespace->delete($ruleId); + $form->removeRule(Database::get(), $this->fetchRule($ruleId)); Notification::success(sprintf( $this->translate('Successfully deleted event rule %s'), - $config['name'] + $form->getValue('name') )); - $this->redirectNow(Links::eventRules()); - } elseif ($form->hasBeenDiscarded()) { - $this->sessionNamespace->delete($ruleId); - Notification::success(sprintf( - $this->translate('Successfully discarded changes to event rule %s'), - $config['name'] - )); - - if ($ruleId === -1) { - $this->switchToSingleColumnLayout(); - } else { - $this->redirectNow(Links::eventRule($ruleId)); - } + $this->switchToSingleColumnLayout(); } }) - ->on(EventRuleConfigForm::ON_CHANGE, function (EventRuleConfigForm $form) use ($ruleId, $config) { - $formValues = $form->getValues(); - $config = array_merge($config, $formValues); - $config['rule_escalation'] = $formValues['rule_escalation']; - $this->sessionNamespace->set($ruleId, $config); - }) - ->handleRequest($this->getServerRequest()); - - $discardChangesButton = null; - if ($fromCache) { - $this->addContent(new EmptyStateBar($this->translate('There are unsaved changes.'))); - $discardChangesButton = new SubmitButtonElement( - 'discard_changes', - [ - 'label' => $this->translate('Discard Changes'), - 'form' => 'event-rule-config-form', - 'class' => 'btn-discard-changes', - 'formnovalidate' => true - ] - ); - } - - $buttonsWrapper = new HtmlElement('div', Attributes::create(['class' => ['icinga-controls', 'save-config']])); - $eventRuleConfigSubmitButton = new SubmitButtonElement( - 'save', - [ - 'label' => $this->translate('Save'), - 'form' => 'event-rule-config-form', - 'disabled' => $fromCache - ] - ); - - $deleteButton = null; - if ($ruleId !== -1) { - $deleteButton = new SubmitButtonElement( - 'delete', - [ - 'label' => $this->translate('Delete'), - 'form' => 'event-rule-config-form', - 'class' => 'btn-remove', - 'formnovalidate' => true - ] - ); - } - - $buttonsWrapper->add([$eventRuleConfigSubmitButton, $discardChangesButton, $deleteButton]); - - $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $config['name']), - (new Link( - new Icon('edit'), - Url::fromPath('notifications/event-rule/edit', [ - 'id' => $ruleId - ]), - ['class' => 'control-button'] - ))->openInModal() - ]); - - $this->addControl($eventRuleForm); - $this->addControl($buttonsWrapper); - $this->addContent($eventRuleConfig); - } - - /** - * Create config from db - * - * @param int $ruleId - * - * @return array - */ - public function fromDb(int $ruleId): array - { - $query = Rule::on(Database::get()) - ->columns(['id', 'name', 'object_filter']) - ->filter(Filter::equal('id', $ruleId)); - - $rule = $query->first(); - if ($rule === null) { - $this->httpNotFound($this->translate('Rule not found')); - } - - $config = iterator_to_array($rule); - - $ruleEscalations = $rule - ->rule_escalation - ->columns(['id', 'position', 'condition']); + ->on(Form::ON_REQUEST, function ( + ServerRequestInterface $request, + EventRuleConfigForm $form + ) use ( + $ruleId, + &$multiPartUpdate + ) { + $nameOnly = (bool) $this->params->shift('_nameOnly'); + $filterOnly = (bool) $this->params->shift('_filterOnly'); + + if ($nameOnly || $filterOnly) { + $multiPartUpdate = true; + + if ($nameOnly) { + $this->addPart($form->prepareObjectFilterUpdate($this->session->get('object_filter'))); + $this->addPart($form->prepareNameUpdate($this->session->get('name'))); + $this->addPart(Html::tag('div', ['id' => 'event-rule-config-name'], [ + Html::tag('h2', $this->session->get('name')), + (new Link( + new Icon('edit'), + Url::fromPath('notifications/event-rule/edit', ['id' => $ruleId]), + ['class' => 'control-button'] + ))->openInModal() + ])); + } else { + $this->addPart($form->prepareNameUpdate($this->session->get('name'))); + $this->addPart($form->prepareObjectFilterUpdate($this->session->get('object_filter'))); + } - foreach ($ruleEscalations as $re) { - foreach ($re as $k => $v) { - if ($k !== 'position') { - $config[$re->getTableName()][$re->position][$k] = (string) $v; - } - } + $this->getResponse()->setHeader('X-Icinga-Location-Query', $this->params->toString()); + } elseif ($ruleId !== -1) { + $rule = $this->fetchRule($ruleId); - $escalationRecipients = $re - ->rule_escalation_recipient - ->columns(['id', 'position', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']); + $form->load($rule); - foreach ($escalationRecipients as $recipient) { - $recipientData = []; + $this->session->set('name', $rule->name); + $this->session->set('object_filter', $rule->object_filter ?? ''); + } else { + $name = $this->params->getRequired('name'); + $form->populate(['id' => $ruleId, 'name' => $name]); - foreach ($recipient as $k => $v) { - if ($v !== null && in_array($k, ['contact_id', 'contactgroup_id', 'schedule_id'])) { - $recipientData[$k] = (string) $v; - } elseif (in_array($k, ['id', 'channel_id'])) { - $recipientData[$k] = $v ? (string) $v : null; - } + $this->session->set('name', $name); + $this->session->set('object_filter', ''); } + }) + ->handleRequest($this->getServerRequest()); - $config[$re->getTableName()][$re->position]['recipients'][] = $recipientData; - } + if ($multiPartUpdate) { + return; } - return $config; + $this->addControl(Html::tag('div', ['class' => 'event-rule-and-save-forms'], [ + Html::tag('div', ['class' => 'event-rule-form', 'id' => 'event-rule-config-name'], [ + Html::tag('h2', $eventRuleConfig->getValue('name')), + (new Link( + new Icon('edit'), + Url::fromPath('notifications/event-rule/edit', ['id' => $ruleId]), + ['class' => 'control-button'] + ))->openInModal() + ]), + Html::tag( + 'div', + ['id' => 'save-config', 'class' => 'icinga-controls'], + $eventRuleConfig->createExternalSubmitButtons() + ) + ])); + + $this->addContent($eventRuleConfig); } /** @@ -231,12 +170,11 @@ public function completeAction(): void */ public function searchEditorAction(): void { - $ruleId = (int) $this->params->shiftRequired('id'); - $config = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); + $ruleId = (int) $this->params->getRequired('id'); $editor = new SearchEditor(); - $editor->setQueryString($config['object_filter'] ?? '') + $editor->setQueryString($this->session->get('object_filter')) ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->setSuggestionUrl( Url::fromPath('notifications/event-rule/complete', [ @@ -246,13 +184,13 @@ public function searchEditorAction(): void ]) ); - $editor->on(Form::ON_SUCCESS, function (SearchEditor $form) use ($ruleId, $config) { - $config['object_filter'] = $this->createFilterString($form->getFilter()); - $this->sessionNamespace->set($ruleId, $config); - $this->closeModalAndRefreshRelatedView(Url::fromPath( - 'notifications/event-rule', - ['id' => $ruleId] - )); + $editor->on(Form::ON_SUCCESS, function (SearchEditor $form) use ($ruleId) { + $filter = (new Renderer($form->getFilter()))->render(); + // TODO: Should not be needed for the new filter implementation + $filter = preg_replace('/(?:=|~|!|%3[EC])(?=[|&]|$)/', '', $filter); + + $this->session->set('object_filter', $filter); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); }); $editor->handleRequest($this->getServerRequest()); @@ -261,45 +199,43 @@ public function searchEditorAction(): void $this->setTitle($this->translate('Adjust Filter')); } - /** - * Create filter string from the given filter rule - * - * @param Filter\Rule $filters - * - * @return ?string - */ - private function createFilterString(Filter\Rule $filters): ?string - { - if ($filters instanceof Filter\Chain) { - foreach ($filters as $filter) { - /** @var Filter\Condition $filter */ - $filter->setValue(true); - } - } elseif ($filters instanceof Filter\Condition && empty($filters->getValue())) { - $filters->setValue(true); - } - - $filterStr = QueryString::render($filters); - - return $filterStr !== '' ? rawurldecode($filterStr) : null; - } - public function editAction(): void { $ruleId = (int) $this->params->getRequired('id'); - $config = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); $eventRuleForm = (new EventRuleForm()) - ->populate($config) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) + ->populate(['name' => $this->session->get('name')]) ->setAction(Url::fromRequest()->getAbsoluteUrl()) - ->on(Form::ON_SUCCESS, function ($form) use ($ruleId, $config) { - $config['name'] = $form->getValue('name'); - $this->sessionNamespace->set($ruleId, $config); - $this->closeModalAndRefreshRemainingViews(Links::eventRule($ruleId)); + ->on(Form::ON_SUCCESS, function ($form) use ($ruleId) { + $this->session->set('name', $form->getValue('name')); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_nameOnly')); })->handleRequest($this->getServerRequest()); $this->setTitle($this->translate('Edit Event Rule')); $this->addContent($eventRuleForm); } + + /** + * Fetch the rule with the given ID + * + * @param int $ruleId + * + * @return Rule + * @throws HttpNotFoundException + */ + private function fetchRule(int $ruleId): Rule + { + $query = Rule::on(Database::get()) + ->filter(Filter::equal('id', $ruleId)); + + /* @var ?Rule $rule */ + $rule = $query->first(); + if ($rule === null) { + $this->httpNotFound(t('Rule not found')); + } + + return $rule; + } } diff --git a/application/controllers/EventRulesController.php b/application/controllers/EventRulesController.php index 1dba38004..5fbabb029 100644 --- a/application/controllers/EventRulesController.php +++ b/application/controllers/EventRulesController.php @@ -13,8 +13,6 @@ use Icinga\Module\Notifications\Widget\ItemList\ObjectList; use Icinga\Web\Session; use ipl\Html\Form; -use ipl\Html\Html; -use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; use ipl\Web\Control\LimitControl; @@ -23,19 +21,14 @@ use ipl\Web\Layout\DetailedItemLayout; use ipl\Web\Url; use ipl\Web\Widget\ButtonLink; -use ipl\Web\Widget\Link; class EventRulesController extends CompatController { use SearchControls; - /** @var Session\SessionNamespace */ - private Session\SessionNamespace $sessionNamespace; - public function init() { $this->assertPermission('notifications/config/event-rules'); - $this->sessionNamespace = Session::getSession()->getNamespace('notifications'); } public function indexAction(): void @@ -47,8 +40,8 @@ public function indexAction(): void $sortControl = $this->createSortControl( $eventRules, [ - 'name' => t('Name'), - 'changed_at' => t('Changed At') + 'name' => $this->translate('Name'), + 'changed_at' => $this->translate('Changed At') ] ); @@ -79,23 +72,14 @@ public function indexAction(): void $this->addControl($limitControl); $this->addControl($searchBar); - $addButton = + $this->addContent( (new ButtonLink( - t('Add Event Rule'), + $this->translate('Add Event Rule'), Url::fromPath('notifications/event-rules/add'), - 'plus' - ))->openInModal(); - if (isset($this->sessionNamespace->{-1})) { - $this->addContent(Html::tag('div', ['class' => 'add-new-component'], [ - $addButton->disable($this->translate( - 'You have unsaved changes. Please save or discard them first.' - )), - (new Link($this->translate('Continue Editing'), Links::eventRule(-1))) - ->setBaseTarget('_next') - ])); - } else { - $this->addContent($addButton->addAttributes(['class' => 'add-new-component'])); - } + 'plus', + ['class' => 'add-new-component'] + ))->openInModal() + ); $this->addContent( (new ObjectList($eventRules, new EventRuleRenderer())) @@ -116,12 +100,12 @@ public function addAction(): void $eventRuleForm = (new EventRuleForm()) ->populate(['id' => -1]) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function ($form) { - $this->sessionNamespace->set(-1, ['id' => -1, 'name' => $form->getValue('name')]); $this->sendExtraUpdates(['#col1']); $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); - $this->redirectNow(Links::eventRule(-1)); + $this->redirectNow(Links::eventRule(-1)->addParams(['name' => $form->getValue('name')])); })->handleRequest($this->getServerRequest()); $this->addContent($eventRuleForm); diff --git a/application/forms/EventRuleConfigElements/ConfigProvider.php b/application/forms/EventRuleConfigElements/ConfigProvider.php new file mode 100644 index 000000000..93ca37653 --- /dev/null +++ b/application/forms/EventRuleConfigElements/ConfigProvider.php @@ -0,0 +1,35 @@ +provider = $provider; + } + + protected function registerAttributeCallbacks(Attributes $attributes): void + { + $attributes->registerAttributeCallback('provider', null, $this->setProvider(...)); + + parent::registerAttributeCallbacks($attributes); + } +} diff --git a/application/forms/EventRuleConfigElements/ConfigProviderInterface.php b/application/forms/EventRuleConfigElements/ConfigProviderInterface.php new file mode 100644 index 000000000..609884ead --- /dev/null +++ b/application/forms/EventRuleConfigElements/ConfigProviderInterface.php @@ -0,0 +1,41 @@ + Properties {@see Contact::$id} and {@see Contact::$full_name} are required. + */ + public function fetchContacts(): iterable; + + /** + * Get a list of contact groups to choose as part of a {@see EscalationRecipient} + * + * @return iterable Properties {@see Contactgroup::$id} and {@see Contactgroup::$name} are required. + */ + public function fetchContactGroups(): iterable; + + /** + * Get a list of schedules to choose as part of a {@see EscalationRecipient} + * + * @return iterable Properties {@see Schedule::$id} and {@see Schedule::$name} are required. + */ + public function fetchSchedules(): iterable; + + /** + * Get a list of channels to choose as part of a {@see EscalationRecipient} + * + * @return iterable Properties {@see Channel::$id} and {@see Channel::$name} are required. + */ + public function fetchChannels(): iterable; +} diff --git a/application/forms/EventRuleConfigElements/DynamicElements.php b/application/forms/EventRuleConfigElements/DynamicElements.php new file mode 100644 index 000000000..a13c83867 --- /dev/null +++ b/application/forms/EventRuleConfigElements/DynamicElements.php @@ -0,0 +1,119 @@ +createElement('submitButton', sprintf('remove_%d', $no), [ + 'formnovalidate' => true + ]); + + $this->registerElement($remove); + + return $remove; + } + + public function populate($values): static + { + if (! isset($values['count'])) { + // Ensure a count is set upon the initial population + $values['count'] = count($values); + } + + return parent::populate($values); + } + + protected function assemble(): void + { + $expectedCount = (int) $this->getPopulatedValue('count', $this->isRequired() ? 1 : 0); + + $count = 0; // Increases until $expectedCount is reached, ensuring proper association with form data + $newCount = 0; // The actual number of restored elements, minus the one that has been removed + while ($count < $expectedCount) { + $remove = $this->createRemoveButton($count); + if ($remove->hasBeenPressed()) { + $this->clearPopulatedValue($remove->getName()); + $this->clearPopulatedValue($count); + + // Re-index populated values to ensure proper association with form data + foreach (range($count + 1, $expectedCount - 1) as $i) { + $expectedValue = $this->getPopulatedValue($i); + if ($expectedValue !== null) { + $this->populate([$i - 1 => $expectedValue]); + } + } + } else { + $newCount++; + } + + $count++; + } + + $add = $this->createAddButton()->addAttributes(['formnovalidate' => true]); + $this->registerElement($add); + if ($add->hasBeenPressed()) { + $this->createRemoveButton($newCount); + $newCount++; + } + + if ($newCount === 1 && $this->isRequired()) { + $this->addElement( + $this->createDynamicElement(0, null) + ->addAttributes(['class' => 'dynamic-item']) + ); + } else { + for ($i = 0; $i < $newCount; $i++) { + /** @var SubmitButtonElement $remove */ + $remove = $this->getElement(sprintf('remove_%d', $i)); + $this->addElement( + $this->createDynamicElement($i, $remove) + ->addAttributes(['class' => 'dynamic-item']) + ); + } + } + + $this->addElement($add); + + $this->clearPopulatedValue('count'); + $this->addElement('hidden', 'count', ['ignore' => true, 'value' => $newCount]); + + $this->addAttributes(['class' => ['dynamic-list', $newCount === 0 ? 'empty' : '']]); + } +} diff --git a/application/forms/EventRuleConfigElements/Escalation.php b/application/forms/EventRuleConfigElements/Escalation.php new file mode 100644 index 000000000..a981e00d9 --- /dev/null +++ b/application/forms/EventRuleConfigElements/Escalation.php @@ -0,0 +1,173 @@ + 'escalation']; + + /** @var ?SubmitButtonElement The button to remove this escalation */ + protected ?SubmitButtonElement $removeButton = null; + + /** @var bool Whether the escalation can be triggered immediately */ + protected bool $immediate = false; + + /** + * Set the button to remove this escalation + * + * @param SubmitButtonElement $removeButton + * + * @return void + */ + public function setRemoveButton(SubmitButtonElement $removeButton): void + { + $this->removeButton = $removeButton; + } + + /** + * Set whether the escalation can be triggered immediately + * + * @param bool $immediate + * + * @return void + */ + public function setImmediate(bool $immediate): void + { + $this->immediate = $immediate; + } + + /** + * Prepare the escalation for display + * + * @param RuleEscalation $escalation + * + * @return array{id: int, conditions: array, recipients: array} + */ + public static function prepare(RuleEscalation $escalation): array + { + return [ + 'id' => $escalation->id, + 'conditions' => EscalationConditions::prepare($escalation->condition ?? ''), + 'recipients' => EscalationRecipients::prepare( + $escalation->rule_escalation_recipient + ->columns(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ), + ]; + } + + /** + * Check whether the escalation position or conditions have changed, according to the given previous escalation + * + * @param RuleEscalation $previousEscalation + * + * @return bool + */ + public function hasChanged(RuleEscalation $previousEscalation): bool + { + if ($previousEscalation->position !== (int) $this->getName()) { + return true; + } + + if ($previousEscalation->condition !== $this->getElement('conditions')->getConditions()) { + return true; + } + + return false; + } + + /** + * Get the escalation to store + * + * @return EscalationType + */ + public function getEscalation(): array + { + $escalationId = null; + if ($this->getElement('id')->hasValue()) { + $escalationId = (int) $this->getElement('id')->getValue(); + } + + return [ + 'id' => $escalationId, + 'position' => (int) $this->getName(), + 'condition' => $this->getElement('conditions')->getConditions() + ]; + } + + /** + * Get the escalation recipients + * + * @return array + */ + public function getRecipients(): array + { + return $this->getElement('recipients')->getRecipients(); + } + + protected function assemble(): void + { + if ($this->removeButton !== null) { + $this->addHtml(new HtmlElement( + 'div', + null, + $this->removeButton->setLabel(new Icon('minus')) + ->setAttribute('class', ['remove-button', 'animated']) + ->setAttribute('title', $this->translate('Remove Escalation')) + )); + } else { + $this->addHtml(new HtmlElement( + 'div', + null, + new HtmlElement('div', Attributes::create(['class' => 'connector-line'])) + )); + } + + $this->addHtml(new HtmlElement('div', Attributes::create(['class' => 'connector-line']))); + + $this->addElement( + (new EscalationConditions('conditions', ['required' => ! $this->immediate])) + ->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'set-wrapper']))) + ); + + $this->addHtml(new HtmlElement('div', Attributes::create(['class' => 'connector-line']))); + + $this->addElement( + (new EscalationRecipients('recipients', [ + 'provider' => $this->provider, + 'required' => true + ])) + ->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'set-wrapper']))) + ); + + $this->addElement('hidden', 'id'); + } + + protected function registerAttributeCallbacks(Attributes $attributes): void + { + $attributes->registerAttributeCallback('immediate', null, $this->setImmediate(...)); + + $this->baseRegisterAttributeCallbacks($attributes); + } +} diff --git a/application/forms/EventRuleConfigElements/EscalationCondition.php b/application/forms/EventRuleConfigElements/EscalationCondition.php new file mode 100644 index 000000000..aaf3cc628 --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationCondition.php @@ -0,0 +1,188 @@ + 'escalation-condition']; + + /** @var ?SubmitButtonElement The button to remove this condition */ + protected ?SubmitButtonElement $removeButton = null; + + /** + * Set the button to remove this condition + * + * @param SubmitButtonElement $removeButton + * + * @return void + */ + public function setRemoveButton(SubmitButtonElement $removeButton): void + { + $this->removeButton = $removeButton; + } + + /** + * Prepare the condition for display + * + * @param Condition $condition + * + * @return ConditionData + */ + public static function prepare(Condition $condition): array + { + $data = [ + 'column' => $condition->getColumn(), + 'operator' => QueryString::getRuleSymbol($condition) + ]; + if ($data['column'] === 'incident_severity') { + $data['severity'] = $condition->getValue(); + } else { + preg_match('/^(\d+)([hms])$/', $condition->getValue(), $matches); + $data['no_of'] = $matches[1]; + $data['unit'] = $matches[2]; + } + + return $data; + } + + /** + * Get the condition to store + * + * @return Condition + */ + public function getCondition(): Condition + { + $this->ensureAssembled(); + + $column = $this->getElement('column')->getValue(); + + $value = match ($column) { + 'incident_severity' => $this->getElement('severity')->getValue(), + 'incident_age' => $this->getElement('no_of')->getValue() + . $this->getElement('unit')->getValue() + }; + + return match ($this->getElement('operator')->getValue()) { + '=' => new Equal($column, $value), + '>' => new GreaterThan($column, $value), + '>=' => new GreaterThanOrEqual($column, $value), + '<' => new LessThan($column, $value), + '<=' => new LessThanOrEqual($column, $value), + '!=' => new Unequal($column, $value) + }; + } + + protected function assemble(): void + { + $this->addElement('select', 'column', [ + 'required' => true, + 'options' => [ + '' => sprintf(' - %s - ', $this->translate('Please choose')), + 'incident_severity' => $this->translate('Incident Severity'), + 'incident_age' => $this->translate('Incident Age') + ], + 'class' => 'autosubmit', + 'disabledOptions' => [''], + 'value' => '' + ]); + $this->addHtml(new Icon('spinner', [ + 'class' => 'spinner', + 'title' => $this->translate( + 'This page will be automatically updated upon change of the value' + ) + ])); + + $this->addElement('select', 'operator', [ + 'required' => true, + 'options' => [ + '=' => '=', + '>' => '>', + '>=' => '>=', + '<' => '<', + '<=' => '<=', + '!=' => '!=' + ] + ]); + + if ($this->getPopulatedValue('column') === 'incident_severity') { + $this->addElement('select', 'severity', [ + 'required' => true, + 'options' => [ + 'ok' => $this->translate('Ok', 'notification.severity'), + 'debug' => $this->translate('Debug', 'notification.severity'), + 'info' => $this->translate('Information', 'notification.severity'), + 'notice' => $this->translate('Notice', 'notification.severity'), + 'warning' => $this->translate('Warning', 'notification.severity'), + 'err' => $this->translate('Error', 'notification.severity'), + 'crit' => $this->translate('Critical', 'notification.severity'), + 'alert' => $this->translate('Alert', 'notification.severity'), + 'emerg' => $this->translate('Emergency', 'notification.severity') + ] + ]); + } elseif ($this->getPopulatedValue('column') === 'incident_age') { + $noOf = $this->createElement('number', 'no_of', [ + 'required' => true, + 'min' => 1, + 'step' => 1, + 'value' => 1 + ]); + $unit = $this->createElement('select', 'unit', [ + 'required' => true, + 'options' => [ + 'h' => $this->translate('Hours'), + 'm' => $this->translate('Minutes'), + 's' => $this->translate('Seconds') + ] + ]); + + $this->registerElement($noOf); + $this->registerElement($unit); + + $this->addHtml(new HtmlElement('div', Attributes::create(['class' => 'age-inputs']), $noOf, $unit)); + } else { + $this->addElement('text', 'noop', [ + 'required' => true, + 'placeholder' => $this->translate('Please make a decision'), + 'disabled' => true + ]); + } + + if ($this->removeButton !== null) { + $this->addHtml( + $this->removeButton->setLabel(new Icon('minus')) + ->setAttribute('class', ['remove-button', 'animated']) + ->setAttribute('title', $this->translate('Remove Condition')) + ); + } else { + $this->addHtml(new HtmlElement('span', Attributes::create([ + 'class' => 'remove-button-disabled', + 'title' => $this->translate('Only the first escalation can be immediately triggered') + ]), (new Icon('minus')))); + } + } +} diff --git a/application/forms/EventRuleConfigElements/EscalationConditions.php b/application/forms/EventRuleConfigElements/EscalationConditions.php new file mode 100644 index 000000000..f45a11a71 --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationConditions.php @@ -0,0 +1,93 @@ + 'escalation-conditions']; + + protected function createAddButton(): SubmitButtonElement + { + /** @var SubmitButtonElement $button */ + $button = $this->createElement('submitButton', 'add-button', [ + 'title' => $this->translate('Add Condition'), + 'label' => new Icon('plus'), + 'class' => ['add-button', 'animated'] + ]); + + $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); + + return $button; + } + + protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement + { + $condition = new EscalationCondition($no); + if ($removeButton !== null) { + $condition->setRemoveButton($removeButton); + } + + return $condition; + } + + /** + * Prepare the conditions for display + * + * @param string $query The query string + * + * @return array + */ + public static function prepare(string $query): array + { + $filters = QueryString::parse($query); + if ($filters instanceof Condition) { + $filters = [$filters]; + } + + $conditions = []; + foreach ($filters as $condition) { + $conditions[] = EscalationCondition::prepare($condition); + } + + return $conditions; + } + + /** + * Get the conditions to store + * + * @return ?string + */ + public function getConditions(): ?string + { + $filters = Filter::all(); + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof EscalationCondition) { + $filters->add($element->getCondition()); + } + } + + if ($filters->isEmpty()) { + return null; + } + + return (new FilterRenderer($filters))->render(); + } +} diff --git a/application/forms/EventRuleConfigElements/EscalationRecipient.php b/application/forms/EventRuleConfigElements/EscalationRecipient.php new file mode 100644 index 000000000..5001ea39a --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationRecipient.php @@ -0,0 +1,202 @@ + 'escalation-recipient']; + + /** @var ?SubmitButtonElement The button to remove this recipient */ + protected ?SubmitButtonElement $removeButton = null; + + /** + * Set the button to remove this recipient + * + * @param SubmitButtonElement $removeButton + * + * @return void + */ + public function setRemoveButton(SubmitButtonElement $removeButton): void + { + $this->removeButton = $removeButton; + } + + /** + * Prepare the recipient for display + * + * @param RuleEscalationRecipient $recipient + * + * @return array + */ + public static function prepare(RuleEscalationRecipient $recipient): array + { + if ($recipient->contact_id !== null) { + $typeAndId = sprintf('contact:%u', $recipient->contact_id); + } elseif ($recipient->contactgroup_id !== null) { + $typeAndId = sprintf('contactgroup:%u', $recipient->contactgroup_id); + } else { + $typeAndId = sprintf('schedule:%u', $recipient->schedule_id); + } + + return [ + 'id' => (string) $recipient->id, + 'channel_id' => $recipient->channel_id !== null ? (string) $recipient->channel_id : null, + 'recipient' => $typeAndId + ]; + } + + /** + * Check whether the recipient has changed, according to the given previous recipient + * + * @param RuleEscalationRecipient $previousRecipient + * + * @return bool + */ + public function hasChanged(RuleEscalationRecipient $previousRecipient): bool + { + return self::prepare($previousRecipient) != $this->getValues(); + } + + /** + * Get the recipient to store + * + * @return RecipientType + */ + public function getRecipient(): array + { + $typeAndId = $this->getElement('recipient')->getValue(); + [$type, $id] = explode(':', $typeAndId, 2); + $typeIdColumn = match ($type) { + 'contact' => 'contact_id', + 'contactgroup' => 'contactgroup_id', + 'schedule' => 'schedule_id' + }; + + $recipientId = null; + if ($this->getElement('id')->hasValue()) { + $recipientId = (int) $this->getElement('id')->getValue(); + } + + $channelId = null; + if ($this->getElement('channel_id')->hasValue()) { + $channelId = (int) $this->getElement('channel_id')->getValue(); + } + + return [ + 'id' => $recipientId, + $typeIdColumn => (int) $id, + 'channel_id' => $channelId + ]; + } + + protected function assemble(): void + { + $pleaseChoose = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; + $defaultChannel = ['' => $this->translate('Default Channel')]; + + $this->addElement('hidden', 'id'); + + $this->addElement('select', 'recipient', [ + 'required' => true, + 'options' => $pleaseChoose + $this->selectRecipients(), + 'value' => '', + 'disabledOptions' => [''], + ]); + + $this->addElement('select', 'channel_id', [ + 'options' => $defaultChannel + $this->selectChannels(), + 'value' => '' + ]); + + if ($this->removeButton !== null) { + $this->addHtml( + $this->removeButton->setLabel(new Icon('minus')) + ->setAttribute('class', ['remove-button', 'animated']) + ->setAttribute('title', $this->translate('Remove Recipient')) + ); + } else { + $this->addHtml(new HtmlElement('span', Attributes::create([ + 'class' => 'remove-button-disabled', + 'title' => $this->translate('At least one recipient is required') + ]), (new Icon('minus')))); + } + } + + /** + * Create a list of recipients to use in a select element + * + * @return array> + */ + protected function selectRecipients(): array + { + $contacts = []; + foreach ($this->provider?->fetchContacts() ?? [] as $contact) { + $contacts[sprintf('contact:%u', $contact->id)] = $contact->full_name; + } + + $contactgroups = []; + foreach ($this->provider?->fetchContactGroups() ?? [] as $contactgroup) { + $contactgroups[sprintf('contactgroup:%u', $contactgroup->id)] = $contactgroup->name; + } + + $schedules = []; + foreach ($this->provider?->fetchSchedules() ?? [] as $schedule) { + $schedules[sprintf('schedule:%u', $schedule->id)] = $schedule->name; + } + + $recipients = []; + if (! empty($contacts)) { + $recipients[$this->translate('Contacts')] = $contacts; + } + + if (! empty($contactgroups)) { + $recipients[$this->translate('Contact Groups')] = $contactgroups; + } + + if (! empty($schedules)) { + $recipients[$this->translate('Schedules')] = $schedules; + } + + return $recipients; + } + + /** + * Create a list of channels to use in a select element + * + * @return array + */ + protected function selectChannels(): array + { + $channels = []; + foreach ($this->provider?->fetchChannels() ?? [] as $channel) { + $channels[$channel->id] = $channel->name; + } + + return $channels; + } +} diff --git a/application/forms/EventRuleConfigElements/EscalationRecipients.php b/application/forms/EventRuleConfigElements/EscalationRecipients.php new file mode 100644 index 000000000..b6c087aac --- /dev/null +++ b/application/forms/EventRuleConfigElements/EscalationRecipients.php @@ -0,0 +1,82 @@ + 'escalation-recipients']; + + protected function createAddButton(): SubmitButtonElement + { + /** @var SubmitButtonElement $button */ + $button = $this->createElement('submitButton', 'add-button', [ + 'title' => $this->translate('Add Recipient'), + 'label' => new Icon('plus'), + 'class' => ['add-button', 'animated'] + ]); + + $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); + + return $button; + } + + protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement + { + $recipient = new EscalationRecipient($no, ['provider' => $this->provider]); + if ($removeButton !== null) { + $recipient->setRemoveButton($removeButton); + } + + return $recipient; + } + + /** + * Prepare the recipients for display + * + * @param iterable $recipients + * + * @return array + */ + public static function prepare(iterable $recipients): array + { + $values = []; + foreach ($recipients as $recipient) { + $values[] = EscalationRecipient::prepare($recipient); + } + + return $values; + } + + /** + * Get the recipients to store + * + * @return array + */ + public function getRecipients(): array + { + $recipients = []; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof EscalationRecipient) { + $recipients[] = $element; + } + } + + return $recipients; + } +} diff --git a/application/forms/EventRuleConfigElements/Escalations.php b/application/forms/EventRuleConfigElements/Escalations.php new file mode 100644 index 000000000..f3defd278 --- /dev/null +++ b/application/forms/EventRuleConfigElements/Escalations.php @@ -0,0 +1,82 @@ + 'escalations']; + + protected function createAddButton(): SubmitButtonElement + { + /** @var SubmitButtonElement $button */ + $button = $this->createElement('submitButton', 'add-button', [ + 'title' => $this->translate('Add Escalation'), + 'label' => new Icon('plus'), + 'class' => ['add-button', 'animated'] + ]); + + $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); + + return $button; + } + + protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement + { + $escalation = new Escalation($no, ['provider' => $this->provider, 'immediate' => $no === 0]); + if ($removeButton !== null) { + $escalation->setRemoveButton($removeButton); + } + + return $escalation; + } + + /** + * Prepare the escalations for display + * + * @param iterable $escalations + * + * @return array + */ + public static function prepare(iterable $escalations): array + { + $values = []; + foreach ($escalations as $escalation) { + $values[] = Escalation::prepare($escalation); + } + + return $values; + } + + /** + * Get the escalations to store + * + * @return array + */ + public function getEscalations(): array + { + $escalations = []; + foreach ($this->ensureAssembled()->getElements() as $element) { + if ($element instanceof Escalation) { + $escalations[] = $element; + } + } + + return $escalations; + } +} diff --git a/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php b/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php deleted file mode 100644 index 81a8c581e..000000000 --- a/application/forms/EventRuleConfigElements/EventRuleConfigFilter.php +++ /dev/null @@ -1,95 +0,0 @@ - 'config-filter']; - - public function __construct(Url $searchEditorUrl, ?string $filter) - { - $this->searchEditorUrl = $searchEditorUrl; - $this->objectFilter = $filter; - - parent::__construct('config-filter'); - } - - /** - * Get the event rule's object filter - * - * @return ?string - */ - public function getObjectFilter(): ?string - { - return $this->objectFilter; - } - - protected function assemble(): void - { - if (! $this->getObjectFilter()) { - $addFilterButton = new SubmitButtonElement( - 'add-filter', - [ - 'class' => ['add-button', 'control-button', 'spinner'], - 'label' => new Icon('plus'), - 'formnovalidate' => true, - 'title' => $this->translate('Add filter') - ] - ); - $this->registerElement($addFilterButton); - - if ($addFilterButton->hasBeenPressed()) { - $this->removeAttribute('class', 'empty-filter'); - } else { - $this->addAttributes(['class' => 'empty-filter']); - $this->addHtml($addFilterButton); - - return; - } - } - - $editorOpener = new Link( - new Icon('cog'), - $this->searchEditorUrl, - Attributes::create([ - 'class' => ['search-editor-opener', 'control-button'], - 'title' => $this->translate('Adjust Filter'), - 'data-icinga-modal' => true, - 'data-no-icinga-ajax' => true, - ]) - ); - - $searchBar = new TextElement( - 'searchbar', - [ - 'class' => ['filter-input', 'control-button'], - 'readonly' => true, - 'value' => $this->getObjectFilter() - ] - ); - - $filterElement = new HtmlElement( - 'div', - Attributes::create(['class' => ['search-controls', 'icinga-controls']]) - ); - - $filterElement->addHtml($searchBar, $editorOpener); - - $this->addHtml($filterElement); - } -} diff --git a/application/forms/EventRuleConfigElements/NotificationConfigProvider.php b/application/forms/EventRuleConfigElements/NotificationConfigProvider.php new file mode 100644 index 000000000..61fabf698 --- /dev/null +++ b/application/forms/EventRuleConfigElements/NotificationConfigProvider.php @@ -0,0 +1,67 @@ +contacts === null) { + $this->contacts = Contact::on(Database::get()) + ->columns(['id', 'full_name']) + ->execute(); + } + + return $this->contacts; + } + + public function fetchContactGroups(): iterable + { + if ($this->contactGroups === null) { + $this->contactGroups = Contactgroup::on(Database::get()) + ->columns(['id', 'name']) + ->execute(); + } + + return $this->contactGroups; + } + + public function fetchSchedules(): iterable + { + if ($this->schedules === null) { + $this->schedules = Schedule::on(Database::get()) + ->columns(['id', 'name']) + ->execute(); + } + + return $this->schedules; + } + + public function fetchChannels(): iterable + { + if ($this->channels === null) { + $this->channels = Channel::on(Database::get()) + ->columns(['id', 'name']) + ->execute(); + } + + return $this->channels; + } +} diff --git a/application/forms/EventRuleConfigForm.php b/application/forms/EventRuleConfigForm.php index f7f0f0376..34da1ac20 100644 --- a/application/forms/EventRuleConfigForm.php +++ b/application/forms/EventRuleConfigForm.php @@ -1,85 +1,57 @@ ['event-rule-config', 'icinga-form', 'icinga-controls'], + 'class' => ['event-rule-config', 'icinga-controls'], 'name' => 'event-rule-config-form', 'id' => 'event-rule-config-form' ]; - /** @var array */ - protected $config; + /** @var ConfigProviderInterface */ + protected ConfigProviderInterface $configProvider; /** @var Url Search editor URL for the config filter fieldset */ - protected $searchEditorUrl; - - /** @var bool Whether the config has an escalation with no condition */ - protected $hasZeroConditionEscalation = false; + protected Url $searchEditorUrl; /** * Create a new EventRuleConfigForm * - * @param array $config - * @param Url $searchEditorUrl + * @param ConfigProviderInterface $configProvider + * @param Url $searchEditorUrl */ - public function __construct(array $config, Url $searchEditorUrl) + public function __construct(ConfigProviderInterface $configProvider, Url $searchEditorUrl) { - $this->config = $config; + $this->configProvider = $configProvider; $this->searchEditorUrl = $searchEditorUrl; - - $this->on(self::ON_SENT, function () { - $csrf = $this->getElement('CSRFToken'); - - if ($csrf !== null && $csrf->isValid()) { - $config = array_merge($this->config, $this->getValues()); - if ($config !== $this->config) { - $this->emit(self::ON_CHANGE, [$this]); - } - } - }); - } - - public function isValidEvent($event) - { - if ($event === self::ON_CHANGE) { - return true; - } - - return parent::isValidEvent($event); } - public function hasBeenSubmitted() + public function hasBeenSubmitted(): bool { $pressedButton = $this->getPressedSubmitElement(); @@ -90,21 +62,11 @@ public function hasBeenSubmitted() return false; } - /** - * Check whether the config has an escalation with no condition - * - * @return bool - */ - public function hasZeroConditionEscalation(): bool - { - return $this->hasZeroConditionEscalation; - } - protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(); - // Replicate save button outside the form + // Replicate the save button outside the form $this->addElement( 'submitButton', 'save', @@ -114,7 +76,7 @@ protected function assemble(): void ] ); - // Replicate delete button outside the form + // Replicate the delete button outside the form $this->addElement( 'submitButton', 'delete', @@ -124,289 +86,271 @@ protected function assemble(): void ] ); - // Replicate discard_changes button outside the form - $this->addElement( - 'submitButton', - 'discard_changes', - [ - 'hidden' => true, - 'class' => 'primary-submit-btn-duplicate' - ] + $this->addHtml( + new HtmlElement('div', Attributes::create(['class' => 'connector-line'])), + new HtmlElement( + 'div', + Attributes::create(['id' => 'object-filter-controls']), + $this->createObjectFilterControls() + ), + new HtmlElement('div', Attributes::create(['class' => 'connector-line'])) ); - $defaultEscalationPrefix = 1; - - $this->addElement('hidden', 'zero-condition-escalation'); + $escalations = new EventRuleConfigElements\Escalations('escalations', [ + 'provider' => $this->configProvider, + 'required' => true + ]); + $this->addElement($escalations); - if (! isset($this->config['rule_escalation'])) { - $this->getElement('zero-condition-escalation')->setValue($defaultEscalationPrefix); - } + $this->addElement('hidden', 'id', ['required' => true]); - $configFilter = new EventRuleConfigFilter($this->searchEditorUrl, $this->config['object_filter'] ?? null); - $this->registerElement($configFilter); + $name = $this->createElement('hidden', 'name', ['required' => true]); + $this->registerElement($name); + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['id' => 'event-rule-config-form-name', 'hidden' => true]), + $name + )); + } - $addEscalationButton = $this->createElement( - 'submitButton', - 'add-escalation', - [ + /** + * Create and return the controls to configure the object filter + * + * @return ValidHtml + */ + protected function createObjectFilterControls(): ValidHtml + { + $hasFilter = true; + if (empty($this->getPopulatedValue('object_filter'))) { + $addFilterButton = $this->createElement('submitButton', 'add-filter', [ 'class' => ['add-button', 'control-button', 'spinner'], 'label' => new Icon('plus'), - 'title' => $this->translate('Add Escalation'), - 'formnovalidate' => true - ] - ); - - $this->registerElement($addEscalationButton); - $prefixesElement = $this->createElement('hidden', 'prefixes', ['value' => $defaultEscalationPrefix]); - $this->addElement($prefixesElement); - $this->handleAdd(); + 'formnovalidate' => true, + 'title' => $this->translate('Add filter') + ]); + $this->registerElement($addFilterButton); - $prefixes = explode(',', $prefixesElement->getValue()); - $escalationCount = count($prefixes); - $zeroConditionEscalation = $this->getValue('zero-condition-escalation'); - $removePosition = null; - $removeEscalationButtons = []; + if ($addFilterButton->hasBeenPressed()) { + $this->remove($addFilterButton); // De-register the button + } else { + $hiddenInput = $this->createElement('hidden', 'object_filter'); + $this->registerElement($hiddenInput); - if ($escalationCount > 1) { - foreach ($prefixes as $prefix) { - $removeEscalationButtons[$prefix] = $this->createRemoveButton($prefix); - } + $objectFilter = new HtmlElement( + 'div', + Attributes::create(['class' => 'add-button-wrapper']), + $addFilterButton, + $hiddenInput + ); - $removePosition = $this->getValue('remove-escalation'); - if ($removePosition && $escalationCount === 2) { - $removeEscalationButtons = []; + $hasFilter = false; } } - $escalations = []; - $this->hasZeroConditionEscalation = $zeroConditionEscalation !== null; - - foreach ($prefixes as $key => $prefix) { - if ($removePosition === $prefix) { - if ($zeroConditionEscalation === $prefix) { - $zeroConditionEscalation = null; - $this->hasZeroConditionEscalation = false; - } - - unset($prefixes[$key]); - $this->getElement('prefixes')->setValue(implode(',', $prefixes)); - - continue; - } - - $escalationCondition = (new EscalationCondition($prefix, $this)) - ->setCondition($this->config['rule_escalation'][$prefix]['condition'] ?? ''); - $escalationRecipient = (new EscalationRecipient($prefix)) - ->setRecipients($this->config['rule_escalation'][$prefix]['recipients'] ?? []); - $this->registerElement($escalationCondition); - $this->registerElement($escalationRecipient); - - $escalation = new Escalation( - $escalationCondition, - $escalationRecipient, - $removeEscalationButtons[$prefix] ?? null + if ($hasFilter) { + $objectFilter = $this->createElement('text', 'object_filter', ['readonly' => true]); + $this->registerElement($objectFilter); + + $editorOpener = new Link( + new Icon('cog'), + $this->searchEditorUrl, + Attributes::create([ + 'class' => ['search-editor-opener', 'control-button'], + 'title' => $this->translate('Adjust Filter'), + 'data-icinga-modal' => true, + 'data-no-icinga-ajax' => true + ]) ); - if ($zeroConditionEscalation === $prefix && $escalation->addConditionHasBeenPressed()) { - $this->hasZeroConditionEscalation = false; - $zeroConditionEscalation = null; - } elseif ($escalation->lastConditionHasBeenRemoved()) { - $this->hasZeroConditionEscalation = true; - $zeroConditionEscalation = $prefix; - } - - $escalations[] = $escalation; + $objectFilter = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-controls']), + $objectFilter, + $editorOpener + ); } - $this->getElement('zero-condition-escalation')->setValue($zeroConditionEscalation); - - $this->addHtml( - (new HtmlElement('div', Attributes::create(['class' => 'filter-wrapper']))) - ->addHtml( - (new FlowLine())->getRightArrow(), - $configFilter, - (new FlowLine())->getHorizontalLine() - ) - ); + return $objectFilter; + } - $this->addHtml(new HtmlElement( + /** + * Get the element to update in case the name of the rule is changed + * + * @param string $newName + * + * @return ValidHtml + */ + public function prepareNameUpdate(string $newName): ValidHtml + { + return new HtmlElement( 'div', - Attributes::create(['class' => 'escalations-wrapper']), - new Escalations($escalations), - $addEscalationButton - )); + Attributes::create(['id' => 'event-rule-config-form-name']), + $this->createElement('hidden', 'name', ['required' => true, 'value' => $newName]) + ); } /** - * Handle addition of escalations + * Get the element to update in case the object filter of the rule is changed * - * @return void + * @param string $newFilter + * + * @return ValidHtml */ - protected function handleAdd(): void + public function prepareObjectFilterUpdate(string $newFilter): ValidHtml { - $pressedButton = $this->getPressedSubmitElement(); + $this->populate(['object_filter' => $newFilter]); - if ($pressedButton && $pressedButton->getName() === 'add-escalation') { - $this->clearPopulatedValue('prefixes'); - $prefixesString = $this->getValue('prefixes', ''); - $prefixesMap = explode(',', $prefixesString); - $escalationFakePos = random_int(-1000, -1); - $prefixesMap[] = $escalationFakePos; - $this->getElement('prefixes') - ->setValue(implode(',', $prefixesMap)); - - if ($this->getValue('zero-condition-escalation') === null) { - $this->getElement('zero-condition-escalation') - ->setValue($escalationFakePos); - } - } + return new HtmlElement( + 'div', + Attributes::create(['id' => 'object-filter-controls']), + $this->createObjectFilterControls() + ); } - public function populate($values): self + /** + * Create and return the submit-buttons for the form + * + * @return SubmitButtonElement[] + */ + public function createExternalSubmitButtons(): array { - if (! isset($values['rule_escalation'])) { - return parent::populate($values); - } - - $values['prefixes'] = $this->getPrefixes(count($values['rule_escalation'])); - $zeroConditionEscalation = array_filter($values['rule_escalation'], function ($escalation) { - return $escalation['condition'] === ''; - }); - - if (! empty($zeroConditionEscalation)) { - $values['zero-condition-escalation'] = array_key_first($zeroConditionEscalation); + $buttons = []; + + if ((int) $this->getValue('id') !== -1) { + $buttons[] = $this->createElement('submitButton', 'delete', [ + 'label' => $this->translate('Delete'), + 'data-progress-label' => $this->translate('Deleting rule'), + 'form' => 'event-rule-config-form', + 'class' => 'btn-remove', + 'formnovalidate' => true + ]); } - foreach ($values['rule_escalation'] as $prefix => $escalation) { - $values['escalation-condition_' . $prefix]['id'] = $escalation['id']; - } + $buttons[] = $this->createElement('submitButton', 'save', [ + 'data-progress-label' => $this->translate('Saving rule'), + 'label' => $this->translate('Save'), + 'form' => 'event-rule-config-form' + ]); - return parent::populate($values); + return $buttons; } /** - * Get the values for the current EventRuleConfigForm + * Load the given event rule into the form * - * @return array values as name-value pairs + * @param Rule $rule + * + * @return void */ - public function getValues(): array + public function load(Rule $rule): void { - $values = []; - $escalations = []; - $prefixesString = $this->getValue('prefixes', ''); - - /** @var string[] $prefixesMap */ - $prefixesMap = explode(',', $prefixesString); - foreach ($prefixesMap as $prefix) { - /** @var EscalationCondition $escalationCondition */ - $escalationCondition = $this->getElement('escalation-condition_' . $prefix); - /** @var EscalationRecipient $escalationRecipient */ - $escalationRecipient = $this->getElement('escalation-recipient_' . $prefix); - $escalations[$prefix]['condition'] = $escalationCondition->getCondition(); - $escalations[$prefix]['id'] = $escalationCondition->getValue('id'); - $escalations[$prefix]['recipients'] = $escalationRecipient->getRecipients(); - } - - /** @var EventRuleConfigFilter $configFilter */ - $configFilter = $this->getElement('config-filter'); - $values['object_filter'] = $configFilter->getObjectFilter(); - $values['rule_escalation'] = $escalations; - - return $values; + $this->populate([ + 'id' => $rule->id, + 'name' => $rule->name, + 'object_filter' => $rule->object_filter, + 'escalations' => EventRuleConfigElements\Escalations::prepare( + $rule->rule_escalation->orderBy('position', 'asc') + ) + ]); } /** - * Create remove button for the given escalation position + * Check whether the name or object filter changed according to the given previous rule * - * @param string $prefix + * @param Rule $previousRule * - * @return SubmitButtonElement + * @return bool */ - protected function createRemoveButton(string $prefix): SubmitButtonElement + protected function hasChanged(Rule $previousRule): bool { - /** @var SubmitButtonElement $button */ - $button = $this->createElement( - 'submitButton', - 'remove-escalation', - [ - 'class' => ['remove-escalation', 'remove-button', 'control-button', 'spinner'], - 'label' => new Icon('minus'), - 'formnovalidate' => true, - 'value' => $prefix, - 'title' => $this->translate('Remove escalation') - ] - ); + if ($previousRule->name !== $this->getValue('name')) { + return true; + } - $this->registerElement($button); + if ($previousRule->object_filter !== $this->getValue('object_filter')) { + return true; + } - return $button; + return false; } /** * Insert to or update event rule in the database and return the id of the event rule * - * @param int $id The id of the event rule - * @param array $config The new configuration + * @param Connection $db + * @param ?Rule $previousRule * * @return int */ - public function addOrUpdateRule(int $id, array $config): int + public function storeInDatabase(Connection $db, ?Rule $previousRule): int { - $db = Database::get(); - $db->beginTransaction(); - if ($id < 0) { + $ruleId = (int) $this->getValue('id'); + if ($previousRule === null) { $db->insert('rule', [ - 'name' => $config['name'], - 'timeperiod_id' => $config['timeperiod_id'] ?? null, - 'object_filter' => $config['object_filter'] ?: null, - 'changed_at' => (int) (new DateTime())->format("Uv") + 'name' => $this->getValue('name'), + 'timeperiod_id' => null, + 'object_filter' => $this->getValue('object_filter'), + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'n' ]); - $id = $db->lastInsertId(); - } else { + $ruleId = (int) $db->lastInsertId(); + } elseif ($this->hasChanged($previousRule)) { $db->update('rule', [ - 'name' => $config['name'], - 'timeperiod_id' => $config['timeperiod_id'] ?? null, - 'object_filter' => $config['object_filter'] ?: null, + 'name' => $this->getValue('name'), + 'object_filter' => $this->getValue('object_filter'), 'changed_at' => (int) (new DateTime())->format("Uv") - ], ['id = ?' => $id]); + ], ['id = ?' => $ruleId]); } - $escalationsFromDb = RuleEscalation::on($db) - ->filter(Filter::equal('rule_id', $id)); + $escalationsFromDb = []; + foreach ($previousRule?->rule_escalation ?? [] as $escalationFromDb) { + /** @var RuleEscalation $escalationFromDb */ + $escalationsFromDb[$escalationFromDb->id] = $escalationFromDb; + } - $escalationsInCache = $config['rule_escalation']; + $recipients = []; + foreach ($this->getElement('escalations')->getEscalations() as $escalation) { + /** @var Escalation $escalation */ + $config = $escalation->getEscalation(); + if ($config['id'] === null) { + $db->insert('rule_escalation', [ + 'rule_id' => $ruleId, + 'position' => $config['position'], + $db->quoteIdentifier('condition') => $config['condition'], + 'name' => null, + 'fallback_for' => null, + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'n' + ]); - $escalationsToUpdate = []; - $escalationsToRemove = []; + $recipients[(int) $db->lastInsertId()] = [$escalation->getRecipients(), []]; + } else { + $escalationFromDb = $escalationsFromDb[$config['id']]; - /** @var RuleEscalation $escalationFromDB */ - foreach ($escalationsFromDb as $escalationFromDB) { - $escalationId = $escalationFromDB->id; - $escalationInCache = array_filter($escalationsInCache, function (array $element) use ($escalationId) { - /** @var string $idInCache */ - $idInCache = $element['id'] ?? null; + $recipientsFromDb = []; + foreach ($escalationFromDb->rule_escalation_recipient as $recipientFromDb) { + $recipientsFromDb[$recipientFromDb->id] = $recipientFromDb; + } - return (int) $idInCache === $escalationId; - }); + $recipients[(int) $config['id']] = [$escalation->getRecipients(), $recipientsFromDb]; - if ($escalationInCache) { - $position = array_key_first($escalationInCache); - // Escalations in DB to update - $escalationsToUpdate[$position] = $escalationInCache[$position]; + if ($escalation->hasChanged($escalationFromDb)) { + $db->update('rule_escalation', [ + 'position' => $config['position'], + $db->quoteIdentifier('condition') => $config['condition'], + 'changed_at' => (int) (new DateTime())->format("Uv") + ], ['id = ?' => $config['id'], 'rule_id = ?' => $ruleId]); + } - unset($escalationsInCache[$position]); - } else { - // Escalation in DB to remove - $escalationsToRemove[] = $escalationId; + unset($escalationsFromDb[$config['id']]); } } - // Escalations to add - $escalationsToAdd = $escalationsInCache; - + // What's left must be removed + $escalationsToRemove = array_keys($escalationsFromDb); if (! empty($escalationsToRemove)) { $db->update('rule_escalation_recipient', [ 'changed_at' => (int) (new DateTime())->format("Uv"), @@ -414,171 +358,82 @@ public function addOrUpdateRule(int $id, array $config): int ], ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n']); $db->update('rule_escalation', [ 'changed_at' => (int) (new DateTime())->format("Uv"), + 'position' => null, 'deleted' => 'y' ], ['id IN (?)' => $escalationsToRemove]); } - if (! empty($escalationsToAdd)) { - $this->insertOrUpdateEscalations($id, $escalationsToAdd, true); - } - - if (! empty($escalationsToUpdate)) { - $this->insertOrUpdateEscalations($id, $escalationsToUpdate); - } - - $db->commitTransaction(); - - return $id; - } - - /** - * Insert to or update escalations in Db - * - * @param int $ruleId - * @param array> $escalations - * @param bool $insert - * - * @return void - */ - private function insertOrUpdateEscalations(int $ruleId, array $escalations, bool $insert = false): void - { - $db = Database::get(); - foreach ($escalations as $position => $escalationConfig) { - $recipientsFromConfig = $escalationConfig['recipients'] ?? []; - if ($insert) { - $db->insert('rule_escalation', [ - 'rule_id' => $ruleId, - 'position' => $position, - $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, - 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null, - 'changed_at' => (int) (new DateTime())->format("Uv") - ]); - - $escalationId = $db->lastInsertId(); - } else { - /** @var string $escalationId */ - $escalationId = $escalationConfig['id']; - $db->update('rule_escalation', [ - 'position' => $position, - $db->quoteIdentifier('condition') => $escalationConfig['condition'] ?? null, - 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null, - 'changed_at' => (int) (new DateTime())->format("Uv") - ], ['id = ?' => $escalationId, 'rule_id = ?' => $ruleId]); - - $recipientsToRemove = []; - $recipients = RuleEscalationRecipient::on($db) - ->columns('id') - ->filter(Filter::equal('rule_escalation_id', $escalationId)); - - /** @var RuleEscalationRecipient $recipient */ - foreach ($recipients as $recipient) { - $recipientId = $recipient->id; - $recipientInCache = array_filter( - $recipientsFromConfig, - function (array $element) use ($recipientId) { - /** @var string $idFromCache */ - $idFromCache = $element['id']; - return (int) $idFromCache === $recipientId; - } - ); - - if (empty($recipientInCache)) { - // Recipients to remove from Db not in cache - $recipientsToRemove[] = $recipientId; + foreach ($recipients as $escalationId => [$escalationRecipients, $recipientsFromDb]) { + foreach ($escalationRecipients as $escalationRecipient) { + /** @var EscalationRecipient $escalationRecipient */ + $config = $escalationRecipient->getRecipient(); + if ($config['id'] === null) { + unset($config['id']); + $db->insert('rule_escalation_recipient', $config + [ + 'rule_escalation_id' => $escalationId, + 'contact_id' => null, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'n' + ]); + } else { + if ($escalationRecipient->hasChanged($recipientsFromDb[$config['id']])) { + $db->update('rule_escalation_recipient', $config + [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + // Ensure unused fields are reset to null + 'contact_id' => null, + 'contactgroup_id' => null, + 'schedule_id' => null + ], ['id = ?' => $config['id']]); } - } - if (! empty($recipientsToRemove)) { - $db->update('rule_escalation_recipient', [ - 'changed_at' => (int) (new DateTime())->format("Uv"), - 'deleted' => 'y' - ], ['id IN (?)' => $recipientsToRemove, 'deleted = ?' => 'n']); + unset($recipientsFromDb[$config['id']]); } } - foreach ($recipientsFromConfig as $recipientConfig) { - $data = [ - 'rule_escalation_id' => $escalationId, - 'channel_id' => $recipientConfig['channel_id'], - 'changed_at' => (int) (new DateTime())->format("Uv") - ]; - - switch (true) { - case isset($recipientConfig['contact_id']): - $data['contact_id'] = $recipientConfig['contact_id']; - $data['contactgroup_id'] = null; - $data['schedule_id'] = null; - - break; - case isset($recipientConfig['contactgroup_id']): - $data['contact_id'] = null; - $data['contactgroup_id'] = $recipientConfig['contactgroup_id']; - $data['schedule_id'] = null; - - break; - case isset($recipientConfig['schedule_id']): - $data['contact_id'] = null; - $data['contactgroup_id'] = null; - $data['schedule_id'] = $recipientConfig['schedule_id']; - - break; - } - - if (! isset($recipientConfig['id'])) { - $db->insert('rule_escalation_recipient', $data); - } else { - $db->update('rule_escalation_recipient', $data, ['id = ?' => $recipientConfig['id']]); - } + $recipientsToRemove = array_keys($recipientsFromDb); + if (! empty($recipientsToRemove)) { + $db->update('rule_escalation_recipient', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['id IN (?)' => $recipientsToRemove, 'deleted = ?' => 'n']); } } - } - /** - * Get whether the delete button was pressed - * - * @return bool - */ - public function hasBeenRemoved(): bool - { - $btn = $this->getPressedSubmitElement(); - $csrf = $this->getElement('CSRFToken'); + $db->commitTransaction(); - return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'delete'; + return $ruleId; } /** - * Get whether the discard button was pressed + * Get whether the delete button was pressed * * @return bool */ - public function hasBeenDiscarded(): bool + public function hasBeenRemoved(): bool { $btn = $this->getPressedSubmitElement(); $csrf = $this->getElement('CSRFToken'); - return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'discard_changes'; + return $csrf->isValid() && $btn !== null && $btn->getName() === 'delete'; } /** * Remove the given event rule * - * @param int $id + * @param Connection $db + * @param Rule $rule * * @return void */ - public function removeRule(int $id): void + public function removeRule(Connection $db, Rule $rule): void { - $db = Database::get(); $db->beginTransaction(); - $escalations = RuleEscalation::on($db) - ->columns('id') - ->filter(Filter::equal('rule_id', $id)); $escalationsToRemove = []; /** @var RuleEscalation $escalation */ - foreach ($escalations as $escalation) { + foreach ($rule->rule_escalation as $escalation) { $escalationsToRemove[] = $escalation->id; } @@ -593,29 +448,12 @@ public function removeRule(int $id): void 'changed_at' => (int) (new DateTime())->format("Uv"), 'position' => null, 'deleted' => 'y' - ], ['rule_id = ?' => $id]); + ], ['rule_id = ?' => $rule->id]); $db->update('rule', [ 'changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y' - ], ['id = ?' => $id]); + ], ['id = ?' => $rule->id]); $db->commitTransaction(); } - - /** - * Get the prefix map - * - * @param int $escalationCount - * - * @return string - */ - protected function getPrefixes(int $escalationCount): string - { - $prefixesMap = []; - for ($i = 1; $i <= $escalationCount; $i++) { - $prefixesMap[] = $i; - } - - return implode(',', $prefixesMap); - } } diff --git a/application/forms/EventRuleForm.php b/application/forms/EventRuleForm.php index 0d4428079..61f0eaa2f 100644 --- a/application/forms/EventRuleForm.php +++ b/application/forms/EventRuleForm.php @@ -4,7 +4,6 @@ namespace Icinga\Module\Notifications\Forms; -use Icinga\Web\Session; use ipl\I18n\Translation; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; @@ -16,7 +15,7 @@ class EventRuleForm extends CompatForm protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(); $this->addElement( 'text', diff --git a/library/Notifications/Widget/FlowLine.php b/library/Notifications/Widget/FlowLine.php deleted file mode 100644 index 20c43926d..000000000 --- a/library/Notifications/Widget/FlowLine.php +++ /dev/null @@ -1,34 +0,0 @@ -setAttributes(['class' => 'right-arrow']); - - return $this; - } - - public function getHorizontalLine() - { - $this->setAttributes(['class' => 'horizontal-line']); - - return $this; - } - - public function getVerticalLine() - { - $this->setAttributes(['class' => 'vertical-line']); - - return $this; - } -} diff --git a/library/Notifications/Widget/RightArrow.php b/library/Notifications/Widget/RightArrow.php deleted file mode 100644 index 0eb2931a5..000000000 --- a/library/Notifications/Widget/RightArrow.php +++ /dev/null @@ -1,15 +0,0 @@ - 'right-arrow']; -} diff --git a/phpstan-baseline-7x.neon b/phpstan-baseline-7x.neon deleted file mode 100644 index 5c9adab60..000000000 --- a/phpstan-baseline-7x.neon +++ /dev/null @@ -1,11 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" - count: 2 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Parameter \\#1 \\$time of function strtotime expects string, mixed given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php diff --git a/phpstan-baseline-8x.neon b/phpstan-baseline-8x.neon deleted file mode 100644 index a26c7dd57..000000000 --- a/phpstan-baseline-8x.neon +++ /dev/null @@ -1,11 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" - count: 2 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Parameter \\#1 \\$datetime of function strtotime expects string, mixed given\\.$#" - count: 1 - path: library/Notifications/Widget/Calendar.php diff --git a/phpstan-baseline-by-php-version.php b/phpstan-baseline-by-php-version.php deleted file mode 100644 index 4bd791e86..000000000 --- a/phpstan-baseline-by-php-version.php +++ /dev/null @@ -1,12 +0,0 @@ -= 80000) { - $includes[] = __DIR__ . '/phpstan-baseline-8x.neon'; -} else { - $includes[] = __DIR__ . '/phpstan-baseline-7x.neon'; -} - -return [ - 'includes' => $includes -]; diff --git a/phpstan-baseline-standard.neon b/phpstan-baseline-standard.neon index d5a77207d..72ae37ff3 100644 --- a/phpstan-baseline-standard.neon +++ b/phpstan-baseline-standard.neon @@ -140,21 +140,6 @@ parameters: count: 4 path: application/controllers/ScheduleController.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\AddEscalationForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/AddEscalationForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\AddFilterForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/AddFilterForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\BaseEscalationForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/BaseEscalationForm.php - - message: "#^Cannot call method getName\\(\\) on ipl\\\\Html\\\\Contract\\\\FormSubmitElement\\|null\\.$#" count: 1 @@ -175,151 +160,11 @@ parameters: count: 1 path: application/forms/DatabaseConfigForm.php - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setName\\(\\)\\.$#" - count: 4 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\ValidHtml\\:\\:remove\\(\\)\\.$#" - count: 1 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#" - count: 2 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getValue\\(\\)\\.$#" - count: 1 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Cannot access offset non\\-falsy\\-string on iterable\\\\.$#" - count: 2 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EscalationConditionForm\\:\\:getValues\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EscalationConditionForm\\:\\:getValues\\(\\) should return array but returns string\\.$#" - count: 1 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Parameter \\#1 \\$string of static method ipl\\\\Web\\\\Filter\\\\QueryString\\:\\:parse\\(\\) expects string, mixed given\\.$#" - count: 1 - path: application/forms/EscalationConditionForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setDisabledOptions\\(\\)\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setName\\(\\)\\.$#" - count: 3 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:setOptions\\(\\)\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Call to an undefined method ipl\\\\Html\\\\ValidHtml\\:\\:remove\\(\\)\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Cannot access offset non\\-falsy\\-string on iterable\\\\.$#" - count: 3 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Cannot access property \\$full_name on mixed\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 3 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Cannot access property \\$name on mixed\\.$#" - count: 2 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EscalationRecipientForm\\:\\:fetchOptions\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EscalationRecipientForm\\:\\:getValues\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/EscalationRecipientForm.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\EventRuleForm\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: application/forms/EventRuleForm.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\RemoveEscalationForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/RemoveEscalationForm.php - - - - message: "#^Call to an undefined method ipl\\\\Sql\\\\Connection\\:\\:lastInsertId\\(\\)\\.$#" - count: 3 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:addRule\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:editRule\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:insertOrUpdateEscalations\\(\\) has parameter \\$escalations with no value type specified in iterable type array\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:insertOrUpdateEscalations\\(\\) has parameter \\$ruleId with no type specified\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:onError\\(\\) has no return type specified\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Forms\\\\SaveEventRuleForm\\:\\:\\$submitLabel \\(string\\) on left side of \\?\\? is not nullable\\.$#" - count: 1 - path: application/forms/SaveEventRuleForm.php - - message: "#^Call to an undefined method ipl\\\\Sql\\\\Connection\\:\\:lastInsertId\\(\\)\\.$#" count: 1 @@ -555,6 +400,11 @@ parameters: count: 1 path: library/Notifications/Web/Form/EventRuleDecorator.php + - + message: "#^Parameter \\#1 \\$datetime of function strtotime expects string, mixed given\\.$#" + count: 1 + path: library/Notifications/Widget/Calendar.php + - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Calendar\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 @@ -660,101 +510,11 @@ parameters: count: 1 path: library/Notifications/Widget/Detail/IncidentQuickActions.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Escalations\\:\\:addEscalation\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Escalations.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Escalations\\:\\:addEscalation\\(\\) has parameter \\$escalation with no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/Escalations.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Escalations\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Escalations.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Escalations\\:\\:\\$config has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Escalations.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\Escalations\\:\\:\\$escalations has no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/Escalations.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:__construct\\(\\) has parameter \\$config with no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:assemble\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:createConditionForm\\(\\) has parameter \\$values with no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:createFilterString\\(\\) has parameter \\$filters with no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:createRecipientForm\\(\\) has parameter \\$values with no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:getConfig\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:getForms\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:setConfig\\(\\) has parameter \\$config with no type specified\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:\\$config type has no value type specified in iterable type array\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - - - message: "#^Property Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventRuleConfig\\:\\:\\$numEscalations is unused\\.$#" - count: 1 - path: library/Notifications/Widget/EventRuleConfig.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\EventSourceBadge\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Widget/EventSourceBadge.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\FlowLine\\:\\:getHorizontalLine\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/FlowLine.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\FlowLine\\:\\:getRightArrow\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/FlowLine.php - - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\FlowLine\\:\\:getVerticalLine\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Notifications/Widget/FlowLine.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Widget\\\\ItemList\\\\PageSeparatorItem\\:\\:assemble\\(\\) has no return type specified\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index 46aa4f530..0ef6b68c4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ includes: - phpstan-baseline-standard.neon - - phpstan-baseline-by-php-version.php parameters: level: max diff --git a/test/php/application/forms/EventRuleConfigFormTest.php b/test/php/application/forms/EventRuleConfigFormTest.php new file mode 100644 index 000000000..462228204 --- /dev/null +++ b/test/php/application/forms/EventRuleConfigFormTest.php @@ -0,0 +1,823 @@ +createMock(ConfigProviderInterface::class); + + $providerMock->expects($this->once()) + ->method('fetchContacts') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchContactGroups') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchSchedules') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchChannels') + ->willReturn([]); + + $requestStub = $this->createStub(ServerRequestInterface::class); + $requestStub->method('getMethod')->willReturn('POST'); + $requestStub->method('getUploadedFiles')->willReturn([]); + $requestStub->method('getParsedBody')->willReturn([ + 'id' => 1337, + 'name' => 'Test' + ]); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + $form->handleRequest($requestStub); + + $elements = $form->getElements(); + $this->assertNotEmpty($elements, 'Form has no elements'); + + // Form must be invalid for one reason only, the escalations + foreach ($elements as $element) { + if ($element->getName() === 'escalations') { + $this->assertFalse($element->isValid(), 'Escalations are not required'); + $this->assertTrue($element->hasElement('0'), 'At least one escalation is required'); + $escalation = $element->getElement('0'); + $this->assertFalse($escalation->isValid(), 'The escalation is not required to have recipients'); + $this->assertTrue($escalation->hasElement('recipients'), 'The escalation has no recipients'); + $recipients = $escalation->getElement('recipients'); + $this->assertFalse($recipients->isValid(), 'The escalation does not require recipients'); + $this->assertTrue($recipients->hasElement('0'), 'At least one recipient is required'); + $recipient = $recipients->getElement('0'); + $this->assertFalse($recipient->isValid(), 'The escalation recipient is not required'); + } else { + $this->assertTrue($element->isValid(), sprintf('Element %s is not valid', $element->getName())); + } + } + } + + /** + * Tests the process of loading, mocking, and storing rule escalations and related entities into the database. + * + * This method verifies the following operations: + * - Mocking and fetching contacts, contact groups, schedules, and channels. + * - Generating rule escalations and their corresponding recipients using mock data. + * - Inserting and updating rule escalations and recipients in the database with proper assertions. + * - Handling deletion conditions and verifying database operations. + * + * What this test does not cover: + * - The actual interaction with the database, which is mocked. + * - The actual handling of form submissions, which is simulated. + * - The insertion and deletion of entire rules. + * + * @return void + */ + public function testLoadAndStorage(): void + { + $start = (int) (new DateTime())->format('Uv'); + + $providerMock = $this->createMock(ConfigProviderInterface::class); + $providerMock->expects($this->exactly(3)) + ->method('fetchContacts') + ->willReturn([ + (new Contact())->setProperties(['id' => 1, 'full_name' => 'Test User 1']), + (new Contact())->setProperties(['id' => 2, 'full_name' => 'Test User 2']) + ]); + $providerMock->expects($this->exactly(3)) + ->method('fetchContactGroups') + ->willReturn([ + (new Contactgroup())->setProperties(['id' => 1, 'name' => 'Test Group']) + ]); + $providerMock->expects($this->exactly(3)) + ->method('fetchSchedules') + ->willReturn([ + (new Schedule())->setProperties(['id' => 1, 'name' => 'Test Schedule']) + ]); + $providerMock->expects($this->exactly(3)) + ->method('fetchChannels') + ->willReturn([ + (new Channel())->setProperties(['id' => 1, 'name' => 'Test Channel']) + ]); + + $dbRule = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => 'servicegroup.name=Test%20Group', + 'timeperiod_id' => null, + 'rule_escalation' => [ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'position' => 0, + 'condition' => 'incident_age>=5m', + 'rule_escalation_recipient' => [ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => null + ]), + (new RuleEscalationRecipient())->setProperties([ + 'id' => 2 + ]) + ] + ]), + (new RuleEscalation())->setProperties(['id' => 2]) + ] + ]); + + $firstRuleEscalationRecipientMock = $this->createMock(Query::class); + $firstRuleEscalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ]); + + $secondRuleEscalationRecipientMock = $this->createMock(Query::class); + $secondRuleEscalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => null, + 'contact_id' => null, + 'contactgroup_id' => 1, + 'schedule_id' => null, + 'channel_id' => null + ]), + (new RuleEscalationRecipient())->setProperties([ + 'id' => null, + 'contact_id' => null, + 'contactgroup_id' => null, + 'schedule_id' => 1, + 'channel_id' => 1 + ]) + ]); + + $ruleEscalationMock = $this->createMock(Query::class); + $ruleEscalationMock->expects($this->once()) + ->method('orderBy') + ->with('position', 'asc') + ->willReturn([ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'condition' => null, + 'rule_escalation_recipient' => $firstRuleEscalationRecipientMock + ]), + (new RuleEscalation())->setProperties([ + 'id' => null, + 'condition' => 'incident_severity>=crit&incident_age>5m', + 'rule_escalation_recipient' => $secondRuleEscalationRecipientMock + ]) + ]); + + $ruleModel = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => 'hostgroup.name=Test%20Group', + 'timeperiod_id' => null, + 'rule_escalation' => $ruleEscalationMock + ]); + + $databaseMock = $this->createMock(Connection::class); + $databaseMock->expects($this->any()) + ->method('quoteIdentifier') + ->willReturnArgument(0); + + $databaseMock->expects($this->exactly(3)) + ->method('insert') + ->willReturnCallback(function ($table, $data) use ($start) { + $this->assertArrayHasKey('changed_at', $data); + $changedAt = $data['changed_at']; + $this->assertGreaterThan($start, $changedAt); + unset($data['changed_at']); + + if ($table === 'rule_escalation') { + $this->assertEquals( + [ + 'rule_id' => 1337, + 'position' => 1, + 'condition' => 'incident_severity>=crit&incident_age>5m', + 'name' => null, + 'fallback_for' => null, + 'deleted' => 'n' + ], + $data + ); + } elseif ($table === 'rule_escalation_recipient') { + if (isset($data['contactgroup_id'])) { + $this->assertEquals( + [ + 'rule_escalation_id' => 2, + 'contact_id' => null, + 'contactgroup_id' => 1, + 'schedule_id' => null, + 'channel_id' => null, + 'deleted' => 'n' + ], + $data + ); + } else { + $this->assertEquals( + [ + 'rule_escalation_id' => 2, + 'contact_id' => null, + 'contactgroup_id' => null, + 'schedule_id' => 1, + 'channel_id' => 1, + 'deleted' => 'n' + ], + $data + ); + } + } else { + $this->fail(sprintf('Unexpected table %s', $table)); + } + }); + + $databaseMock->expects($this->once()) + ->method('lastInsertId') + ->willReturn('2'); + + $databaseMock->expects($this->exactly(6)) + ->method('update') + ->willReturnCallback(function ($table, $data, $where) use ($start) { + $this->assertArrayHasKey('changed_at', $data); + $changedAt = $data['changed_at']; + $this->assertGreaterThan($start, $changedAt); + unset($data['changed_at']); + + if ($table === 'rule') { + $this->assertSame(['id = ?' => 1337], $where); + $this->assertEquals( + [ + 'name' => 'Test', + 'object_filter' => 'hostgroup.name=Test%20Group' + ], + $data + ); + } elseif ($table === 'rule_escalation') { + if (isset($data['deleted'])) { + // This column only exists during deletion + $this->assertEquals( + ['id IN (?)' => [2]], + $where + ); + $this->assertEquals( + [ + 'deleted' => 'y', + 'position' => null + ], + $data + ); + } else { + $this->assertSame(['id = ?' => 1, 'rule_id = ?' => 1337], $where); + $this->assertEquals( + [ + 'position' => 0, + 'condition' => null + ], + $data + ); + } + } elseif ($table === 'rule_escalation_recipient') { + if (isset($data['deleted'])) { + if (isset($where['id IN (?)'])) { + $this->assertEquals( + ['id IN (?)' => [2], 'deleted = ?' => 'n'], + $where + ); + } else { + $this->assertEquals( + ['rule_escalation_id IN (?)' => [2], 'deleted = ?' => 'n'], + $where + ); + } + + $this->assertEquals( + [ + 'deleted' => 'y' + ], + $data + ); + } else { + $this->assertSame(['id = ?' => 1], $where); + $this->assertEquals( + [ + 'id' => 1, // Actually redundant, included for consistency + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ], + $data + ); + } + } else { + $this->fail(sprintf('Unexpected table %s', $table)); + } + }); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + // Not quite realistic, since load() usually fetches consistent data from the database, + // but the test doubles used here, return different data effectively simulating an + // edit operation. Simulating a form-submit would require a lot of knowledge about + // its structure, unsuitable for a unit test. + $form->load($ruleModel); + + $this->assertTrue($form->isValid(), 'Form is not valid'); + + $form->storeInDatabase($databaseMock, $dbRule); + } + + /** + * Covers the case where only a single escalation with a single recipient and no conditions is present + * in the form and no changes are made. + * + * @return void + */ + public function testNoChangesAlsoCauseNoUpdates(): void + { + $providerMock = $this->createMock(ConfigProviderInterface::class); + $providerMock->expects($this->once()) + ->method('fetchContacts') + ->willReturn([ + (new Contact())->setProperties(['id' => 1, 'full_name' => 'Test User 1']) + ]); + $providerMock->expects($this->once()) + ->method('fetchContactGroups') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchSchedules') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchChannels') + ->willReturn([ + (new Channel())->setProperties(['id' => 1, 'name' => 'Test Channel']) + ]); + + $dbRule = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => [ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'position' => 0, + 'condition' => null, + 'rule_escalation_recipient' => [ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ] + ]) + ] + ]); + + $escalationRecipientMock = $this->createMock(Query::class); + $escalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ]); + + $ruleEscalationMock = $this->createMock(Query::class); + $ruleEscalationMock->expects($this->once()) + ->method('orderBy') + ->with('position', 'asc') + ->willReturn([ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'condition' => null, + 'rule_escalation_recipient' => $escalationRecipientMock + ]) + ]); + + $ruleModel = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => $ruleEscalationMock + ]); + + $databaseMock = $this->createMock(Connection::class); + $databaseMock->expects($this->never()) + ->method('insert'); + $databaseMock->expects($this->never()) + ->method('update'); + $databaseMock->expects($this->never()) + ->method('delete'); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + $form->load($ruleModel); + + $this->assertTrue($form->isValid(), 'Form is not valid'); + + $form->storeInDatabase($databaseMock, $dbRule); + } + + /** + * Covers the case where only a single escalation with a single recipient and no conditions is present + * in the form and the rule's object filter is changed. + * + * @return void + */ + public function testIfARuleChangesOnlyTheRuleItselfIsUpdated(): void + { + $start = (int) (new DateTime())->format('Uv'); + + $providerMock = $this->createMock(ConfigProviderInterface::class); + $providerMock->expects($this->once()) + ->method('fetchContacts') + ->willReturn([ + (new Contact())->setProperties(['id' => 1, 'full_name' => 'Test User 1']) + ]); + $providerMock->expects($this->once()) + ->method('fetchContactGroups') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchSchedules') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchChannels') + ->willReturn([ + (new Channel())->setProperties(['id' => 1, 'name' => 'Test Channel']) + ]); + + $dbRule = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => [ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'position' => 0, + 'condition' => null, + 'rule_escalation_recipient' => [ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ] + ]) + ] + ]); + + $escalationRecipientMock = $this->createMock(Query::class); + $escalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ]); + + $ruleEscalationMock = $this->createMock(Query::class); + $ruleEscalationMock->expects($this->once()) + ->method('orderBy') + ->with('position', 'asc') + ->willReturn([ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'condition' => null, + 'rule_escalation_recipient' => $escalationRecipientMock + ]) + ]); + + $ruleModel = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => 'servicegroup.name=Test%20Group', + 'timeperiod_id' => null, + 'rule_escalation' => $ruleEscalationMock + ]); + + $databaseMock = $this->createMock(Connection::class); + $databaseMock->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($table, $data, $where) use ($start) { + $this->assertSame('rule', $table); + $this->assertSame(['id = ?' => 1337], $where); + $this->assertArrayHasKey('changed_at', $data); + $changedAt = $data['changed_at']; + $this->assertGreaterThan($start, $changedAt); + unset($data['changed_at']); + $this->assertEquals( + [ + 'name' => 'Test', + 'object_filter' => 'servicegroup.name=Test%20Group' + ], + $data + ); + }); + $databaseMock->expects($this->never()) + ->method('insert'); + $databaseMock->expects($this->never()) + ->method('delete'); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + $form->load($ruleModel); + + $this->assertTrue($form->isValid(), 'Form is not valid'); + + $form->storeInDatabase($databaseMock, $dbRule); + } + + /** + * Covers the case where only a single escalation with a single recipient and no conditions is present + * in the form and the escalation's conditions are changed. + * + * @return void + */ + public function testIfARuleChangesOnlyTheEscalationIsUpdated(): void + { + $start = (int) (new DateTime())->format('Uv'); + + $providerMock = $this->createMock(ConfigProviderInterface::class); + $providerMock->expects($this->once()) + ->method('fetchContacts') + ->willReturn([ + (new Contact())->setProperties(['id' => 1, 'full_name' => 'Test User 1']) + ]); + $providerMock->expects($this->once()) + ->method('fetchContactGroups') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchSchedules') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchChannels') + ->willReturn([ + (new Channel())->setProperties(['id' => 1, 'name' => 'Test Channel']) + ]); + + $dbRule = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => [ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'position' => 0, + 'condition' => null, + 'rule_escalation_recipient' => [ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ] + ]) + ] + ]); + + $escalationRecipientMock = $this->createMock(Query::class); + $escalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ]); + + $ruleEscalationMock = $this->createMock(Query::class); + $ruleEscalationMock->expects($this->once()) + ->method('orderBy') + ->with('position', 'asc') + ->willReturn([ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'condition' => 'incident_severity>=crit&incident_age>5m', + 'rule_escalation_recipient' => $escalationRecipientMock + ]) + ]); + + $ruleModel = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => $ruleEscalationMock + ]); + + $databaseMock = $this->createMock(Connection::class); + $databaseMock->expects($this->any()) + ->method('quoteIdentifier') + ->willReturnArgument(0); + + $databaseMock->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($table, $data, $where) use ($start) { + $this->assertSame('rule_escalation', $table); + $this->assertSame(['id = ?' => 1, 'rule_id = ?' => 1337], $where); + $this->assertArrayHasKey('changed_at', $data); + $changedAt = $data['changed_at']; + $this->assertGreaterThan($start, $changedAt); + unset($data['changed_at']); + $this->assertEquals( + [ + 'position' => 0, + 'condition' => 'incident_severity>=crit&incident_age>5m' + ], + $data + ); + }); + $databaseMock->expects($this->never()) + ->method('insert'); + $databaseMock->expects($this->never()) + ->method('delete'); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + $form->load($ruleModel); + + $this->assertTrue($form->isValid(), 'Form is not valid'); + + $form->storeInDatabase($databaseMock, $dbRule); + } + + /** + * Covers the case where only a single escalation with a single recipient and no conditions is present + * in the form and the escalation's recipient is changed. + * + * @return void + */ + public function testIfARuleChangesOnlyTheEscalationRecipientIsUpdated(): void + { + $start = (int) (new DateTime())->format('Uv'); + + $providerMock = $this->createMock(ConfigProviderInterface::class); + $providerMock->expects($this->once()) + ->method('fetchContacts') + ->willReturn([ + (new Contact())->setProperties(['id' => 1, 'full_name' => 'Test User 1']) + ]); + $providerMock->expects($this->once()) + ->method('fetchContactGroups') + ->willReturn([ + (new ContactGroup())->setProperties(['id' => 1, 'name' => 'Test Group']) + ]); + $providerMock->expects($this->once()) + ->method('fetchSchedules') + ->willReturn([]); + $providerMock->expects($this->once()) + ->method('fetchChannels') + ->willReturn([ + (new Channel())->setProperties(['id' => 1, 'name' => 'Test Channel']) + ]); + + $dbRule = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => [ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'position' => 0, + 'condition' => null, + 'rule_escalation_recipient' => [ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => 1, + 'contactgroup_id' => null, + 'schedule_id' => null, + 'channel_id' => 1 + ]) + ] + ]) + ] + ]); + + $escalationRecipientMock = $this->createMock(Query::class); + $escalationRecipientMock->expects($this->once()) + ->method('columns') + ->with(['id', 'contact_id', 'contactgroup_id', 'schedule_id', 'channel_id']) + ->willReturn([ + (new RuleEscalationRecipient())->setProperties([ + 'id' => 1, + 'contact_id' => null, + 'contactgroup_id' => 1, + 'schedule_id' => null, + 'channel_id' => null + ]) + ]); + + $ruleEscalationMock = $this->createMock(Query::class); + $ruleEscalationMock->expects($this->once()) + ->method('orderBy') + ->with('position', 'asc') + ->willReturn([ + (new RuleEscalation())->setProperties([ + 'id' => 1, + 'condition' => null, + 'rule_escalation_recipient' => $escalationRecipientMock + ]) + ]); + + $ruleModel = (new Rule())->setProperties([ + 'id' => 1337, + 'name' => 'Test', + 'object_filter' => null, + 'timeperiod_id' => null, + 'rule_escalation' => $ruleEscalationMock + ]); + + $databaseMock = $this->createMock(Connection::class); + $databaseMock->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($table, $data, $where) use ($start) { + $this->assertSame('rule_escalation_recipient', $table); + $this->assertSame(['id = ?' => 1], $where); + $this->assertArrayHasKey('changed_at', $data); + $changedAt = $data['changed_at']; + $this->assertGreaterThan($start, $changedAt); + unset($data['changed_at']); + $this->assertEquals( + [ + 'id' => 1, + 'contact_id' => null, + 'contactgroup_id' => 1, + 'schedule_id' => null, + 'channel_id' => null + ], + $data + ); + }); + $databaseMock->expects($this->never()) + ->method('insert'); + $databaseMock->expects($this->never()) + ->method('delete'); + + $form = new EventRuleConfigForm($providerMock, $this->createStub(Url::class)); + $form->disableCsrfCounterMeasure(); + + $form->load($ruleModel); + + $this->assertTrue($form->isValid(), 'Form is not valid'); + + $form->storeInDatabase($databaseMock, $dbRule); + } +} From 9d5c1c60e9c7ef9052eafafe8237891053ed86d5 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 24 Oct 2023 08:42:48 +0200 Subject: [PATCH 6/6] event-rule: Simplify stylesheet and make it responsive --- .../controllers/EventRuleController.php | 1 + public/css/common.less | 6 - public/css/detail/event-rule-detail.less | 286 +++++++++++++-- public/css/event-rule-config.less | 336 ------------------ public/css/mixins.less | 44 ++- 5 files changed, 279 insertions(+), 394 deletions(-) delete mode 100644 public/css/event-rule-config.less diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index bc03fd300..9d07d6b16 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -42,6 +42,7 @@ public function init(): void public function indexAction(): void { $this->addTitleTab($this->translate('Event Rule')); + $this->controls->addAttributes(['class' => 'event-rule-detail']); $this->content->addAttributes(['class' => 'event-rule-detail']); $this->getTabs()->disableLegacyExtensions(); diff --git a/public/css/common.less b/public/css/common.less index 586b49349..8c50c1669 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -80,12 +80,6 @@ .add-new-component { margin: 0 0 1em 1em; - - &:is(div) { - display: flex; - gap: .5em; - align-items: baseline; - } } .item-layout.rule footer { diff --git a/public/css/detail/event-rule-detail.less b/public/css/detail/event-rule-detail.less index 541fce33c..22a061cf0 100644 --- a/public/css/detail/event-rule-detail.less +++ b/public/css/detail/event-rule-detail.less @@ -1,61 +1,269 @@ -.event-rule-detail { - .empty-state-bar { - margin-bottom: 1em; +.event-rule-config { + @connectorHeight: .5em; + @connectorColor: @gray-lighter; + + // Style + + .connector-line { + background-color: @connectorColor; } -} -.new-event-rule { - margin-bottom: 1em; -} + .filter-controls, + .dynamic-list:not(.empty, .escalations) { + padding: .5em; + border: 1px solid @connectorColor; + .rounded-corners(); + } + + .add-button, + .remove-button { + .event-rule-button(); + + &.animated.active { + .fa::before { + .animate(spin 2s infinite linear); + + // fa spinner + content: '\f110'; + } + } + } + + .remove-button-disabled { + .event-rule-button(true); + } + + .escalation-condition, + .escalation-recipient { + > :first-child, + > :first-child[type="hidden"] + * { + .rounded-corners(.25em); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + > :last-child { + .rounded-corners(.25em); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + > :not(:first-child, :first-child[type="hidden"] + *, :last-child) { + .rounded-corners(0); + + * { + // nested inputs as well + .rounded-corners(0); + } + } + } + + // Layout + + display: flex; + + .connector-line { + height: @connectorHeight; + margin-top: 2.75em; + } + + > .connector-line { + flex: 1 1 auto; + min-width: 1em; + } + + #object-filter-controls { + width: 20em; + height: fit-content; + + .filter-controls { + margin-top: 1.25em; + display: flex; + gap: .5em; + + input[type="text"] { + flex: 1 1 auto; + width: 0; + } + } + } + + .dynamic-list.empty .add-button, + > #object-filter-controls > .add-button-wrapper .add-button { + align-self: flex-start; + padding: 1.25em 2em; + margin-top: 1.25em; + } + + .dynamic-list.empty > .add-button-wrapper, + > #object-filter-controls > .add-button-wrapper { + display: flex; + + &::before, + &::after { + content: ""; + flex: 1 1 auto; + height: @connectorHeight; + margin-top: 2.75em; + background-color: @connectorColor; + } + + .add-button { + flex: 0; + } + } -.event-rule-form { - display: inline-flex; - width: fit-content; - max-width: unset; - align-items: flex-start; + > #object-filter-controls > .add-button-wrapper { + min-width: 12em; + } - > h2 { - margin: 0 0 0.5em 0; + > .escalations { + flex: 15 1 auto; } - .control-group { - display: inline-flex; - margin-right: 2em; + .escalations { + display: grid; + grid-template-columns: min-content minmax(1em, 4em) 1fr minmax(1em, 4em) 1fr; + + .escalation { + display: contents; + } + + .escalation > div:first-child { + .vertical-line(); + + .remove-button { + margin-top: 2em; + } + } + + // The first escalation isn't connected to anything on top of it + .escalation:first-child > div:first-child::before { + top: 2.75em; + } + + > .add-button-wrapper { + .vertical-line(); + + .add-button { + margin-top: 2em; + } + } + + .escalation .dynamic-list { + position: relative; + margin-bottom: 1em; + + .dynamic-item { + display: grid; + align-items: baseline; + margin-bottom: .5em; + gap: 1px; + + &.escalation-condition { + grid-template-columns: minmax(8em, 1fr) 0fr 4em minmax(8em, 1fr) min-content; + + .age-inputs { + display: flex; + gap: 1px; + + > * { + flex: 1 1 auto; + width: 0; + } + } + } + + &.escalation-recipient { + grid-template-columns: minmax(8em, 1fr) minmax(8em, 1fr) min-content; + } + + input[type="text"], + .remove-button, + .remove-button-disabled { + height: 100%; + } + } - .control-label-group { - width: auto; + .add-button { + width: 100%; + + > .icon { + margin: 0 auto; + } + } } + } + + select:not([multiple]) + .spinner::before { + // ipl-web's Icon applies a min-width by default. Since the spinner element is part of a flex container, + // it will be larger than necessary, covering the select's dropdown arrow. This ensures it doesn't. + width: 1em; + } + + .vertical-line() { + position: relative; - input[type='text'] { - max-width: unset; - width: 25em; + &::before { + position: absolute; + z-index: -1; + @halfedConnectorHeight: @connectorHeight / 2; + inset: 0 ~"calc(50% - @{halfedConnectorHeight})" 0 ~"calc(50% - @{halfedConnectorHeight})"; + + content: ""; + width: @connectorHeight; + + background-color: @connectorColor; } } } -.save-config { - display: inline-flex; - float: right; - width: fit-content; - flex-direction: row-reverse; +#layout.twocols:not(.wide-layout) { + .event-rule-config { + #object-filter-controls { + width: fit-content; - button[type="submit"] { - margin-right: 1em; + input[type="text"] { + display: none; + } + } - &.btn-remove:not([disabled]) { - .button(@body-bg-color, @color-critical, @color-critical-accentuated); - border: none; + .dynamic-list.empty .add-button, + > #object-filter-controls > .add-button-wrapper .add-button { + padding: .5em 1em; + margin-top: 2em; } - &.btn-discard-changes { - .event-rule-button(); + > #object-filter-controls > .add-button-wrapper { + min-width: fit-content; } + } +} + +// Other stuff + +.event-rule-and-save-forms { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: .5em; - &:disabled { - background: @gray-light; - color: @disabled-gray; - cursor: not-allowed; - border: transparent; + .event-rule-form { + display: flex; + gap: .5em; + + h2 { + margin: 0; + } + } + + #save-config { + display: flex; + gap: .5em; + max-height: 2em; + + .btn-remove { + .button(@body-bg-color, @color-critical, @color-critical-accentuated); } } } diff --git a/public/css/event-rule-config.less b/public/css/event-rule-config.less deleted file mode 100644 index 502162990..000000000 --- a/public/css/event-rule-config.less +++ /dev/null @@ -1,336 +0,0 @@ -.event-rule-config { - display: flex; - align-items: center; - ul { - list-style-type: none; - margin: 0; - li { - display: inline-flex; - align-items: center; - } - } - - .escalations { - padding: 0; - position: relative; - - > .escalation:first-child:before { - content: ""; - display: block; - top: 2em; - } - - > .escalation:before { - content: ""; - display: block; - position: absolute; - top: 1.25em; - bottom: 0; - width: 0.5em; - margin-left: 1.25em; - background: var(--gray-lighter, #4b4b4b); - z-index: -1; - } - } - - .config-filter + .add-button { - align-self: flex-end; - } - - .filter-wrapper { - display: inline-flex; - align-self: flex-start; - } - - .filter-wrapper:has(.config-filter .search-controls) { - align-items: baseline; - } - - .add-escalation { - width: fit-content; - display: block; - } - - .right-arrow, - .horizontal-line { - display: inline-block; - background-color: @base-gray-lighter; - height: 0.5em; - text-align: end; - } - - .right-arrow { - width: 10em; - min-width: 2em; - margin-right: 0.4em; - position: relative; - } - - .horizontal-line { - width: 3em; - min-width: 1em; - } - - .right-arrow:after { - content: ''; - position: absolute; - border: 0.3em solid transparent; - border-left: 0.4em solid @base-gray-lighter; - } - - .vertical-line { - width: 0.7em; - background-color: @base-gray-lighter; - } - - .escalation { - margin-bottom: 2em; - .remove-button { - align-self: flex-start; - - &:disabled { - background: @gray-light; - color: @disabled-gray; - cursor: not-allowed; - border-color: transparent; - } - } - - .horizontal-line { - min-width: 3.5em; - } - - .right-arrow { - min-width: 10em; - } - - .zero-escalation-condition + .right-arrow, - .right-arrow:has(+ .zero-escalation-condition) { - min-width: calc(~"40% - 0.85em"); - } - - .remove-escalation { - margin-top: 1.25em; - } - } - - .config-filter { - align-self: flex-start; - padding-top: 0.5em; - } - - .search-controls { - display: inline-flex; - width: 20em; - min-width: unset; - padding: 0.5em; - border: 1px solid @gray-lighter; - border-radius: 0.5em; - - input.filter-input { - width: 20em; - background-color: @search-term-bg; - color: @search-term-color; - } - } -} - -.filter-wrapper, -.escalations { - .horizontal-line, - .right-arrow { - margin-top: 2em; - align-self: flex-start; - } -} - -.horizontal-line { - min-width: 10em; -} - -.remove-button, -.add-button { - .event-rule-button(); -} - -.escalation-condition, -.escalation-recipient { - width: 100%; - padding: 0.5em; - border: 1px solid @gray-lighter; - border-radius: 0.5em; - align-self: flex-start; - - .escalation-condition-list, - .escalation-recipient-list { - list-style-type: none; - padding: 0; - margin: 0; - - > li { - display: flex; - margin-bottom: .4em; - - &.escaltion-condition-list-item, - &.escaltion-recipient-list-item { - .errors { - display: inline-flex; - width: fit-content; - margin: 0; - } - - .errors + .remove-button { - margin: 0; - } - } - } - - .default-channel { - color: @disabled-gray; - } - - select, input { - min-width: 10em; - text-align: center; - height: 2.25em; - line-height: normal; - background: @search-term-bg; - color: @search-term-color; - } - - select { - background-image: url('@{iplWebAssets}/img/select-icon.svg'); - background-position: center right; - background-repeat: no-repeat; - } - - .left-operand { - border-radius: 0.4em 0 0 0.4em; - } - - .right-operand { - border-radius: 0 0.4em 0.4em 0; - width: 0; - flex: 1 1 auto; - margin-left: 1px; - } - - .operator-input { - min-width: unset; - padding-right: 0.5em; - width: 3em; - border-radius: unset; - margin: 0 1px; - background: @search-term-bg; - color: @search-term-color; - - .escaltion-condition-list-item, - .escaltion-recipient-list-item { - text-align: center; - } - } - } - - .remove-button { - height: 2.25em; - margin-left: 0.5em; - } - - input::-webkit-calendar-picker-indicator { - display: none; - } -} - -.escalation-recipient .left-operand { - margin-right: 1px; -} - -.escalation-condition, -.escalation-recipient { - .escalation-condition-list + .add-button, - .escalation-recipient-list + .add-button, - .remove-button { - .event-rule-button(); - } - - .escalation-condition-list + .add-button, - .escalation-recipient-list + .add-button { - width: calc(~"100% - 3.5em"); - } - - .escalation-condition-list li, - .escalation-recipient-list li { - input, select { - &:last-child:not(.remove-button) { - margin-right: 3.5em; - } - } - } -} - -.config-filter.empty-filter, -.escalation-condition.zero-escalation-condition { - border: 0; - padding: 0; - button[type="submit"] { - width: 100%; - font-size: 2em; - height: 2.25em; - margin: 0; - - > .icon { - flex-wrap: wrap; - align-content: flex-start; - } - - .event-rule-button(); - } -} - -.config-filter.empty-filter:after { - content: 'Filter'; - display: block; - text-align: center; - color: @text-color-light; -} - -.zero-escalation-condition:after { - content: 'Condition'; - display: block; - text-align: center; - color: @text-color-light; -} - -.event-rule-button() { - color: @icinga-blue; - background: @low-sat-blue; - - border: none; - text-align: center; - line-height: 1.5; - display: block; - - &:hover, - &:focus { - color: @icinga-blue; - } - - &:hover { - background: @low-sat-blue-dark; - } - - &:focus { - outline: 3px solid fade(@icinga-blue, 50%); - outline-offset: 1px; - } -} - -.submit-btn-duplicate { - border: 0; - height: 0; - margin: 0; - padding: 0; - visibility: hidden; - width: 0; - position: absolute; -} diff --git a/public/css/mixins.less b/public/css/mixins.less index d2463f6b4..3bf39fa11 100644 --- a/public/css/mixins.less +++ b/public/css/mixins.less @@ -1,20 +1,38 @@ -.event-rule-button() { - color: @icinga-blue; - background: @low-sat-blue; - - border: none; +.event-rule-button(@disabled: false) { text-align: center; line-height: 1.5; - display: block; + display: flex; + align-items: center; + padding: .5em 1em; - &:hover, - &:focus { - background: @low-sat-blue-dark; + & when (@disabled = false) { + border: none; color: @icinga-blue; + background: @low-sat-blue; + + &:hover, + &:focus { + background: @low-sat-blue-dark; + color: @icinga-blue; + } + + &:focus { + outline: 3px solid fade(@icinga-blue, 50%); + outline-offset: 1px; + } + } + + & when (@disabled = true) { + padding-left: ~"calc(1em - 1px)"; + padding-right: ~"calc(1em - 1px)"; + border: 1px solid @control-disabled-color; + color: @control-disabled-color; + cursor: not-allowed; + + .user-select(none); } - &:focus { - outline: 3px solid fade(@icinga-blue, 50%); - outline-offset: 1px; + .icon::before { + margin-right: 0; } -} \ No newline at end of file +}