diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index c96c88bcd..9d07d6b16 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -4,14 +4,15 @@ 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\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\Form; @@ -19,155 +20,134 @@ use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\Renderer; use ipl\Web\Url; 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 $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->assertPermission('notifications/config/event-rules'); - - $this->addTitleTab(t('Event Rule')); - + $this->addTitleTab($this->translate('Event Rule')); $this->controls->addAttributes(['class' => 'event-rule-detail']); + $this->content->addAttributes(['class' => 'event-rule-detail']); + $this->getTabs()->disableLegacyExtensions(); - $ruleId = $this->params->getRequired('id'); + $ruleId = (int) $this->params->getRequired('id'); - $cache = $this->sessionNamespace->get($ruleId); + $multiPartUpdate = false; + $eventRuleConfig = (new EventRuleConfigForm( + new NotificationConfigProvider(), + Url::fromPath('notifications/event-rule/search-editor', ['id' => $ruleId]) + ))->setCsrfCounterMeasureId(Session::getSession()->getId()); - 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) - ); - } - - $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)); - } - - if (! $eventRuleConfig->isValid()) { - $eventRuleConfig->addAttributes(['class' => 'invalid']); - return; + $eventRuleConfig + ->on(Form::ON_SUCCESS, function (EventRuleConfigForm $form) use ($ruleId) { + if ($ruleId !== -1) { + $rule = $this->fetchRule($ruleId); + } else { + $rule = null; } - $form->editRule($ruleId, $this->sessionNamespace->get($ruleId)); - $this->sessionNamespace->delete($ruleId); - - Notification::success($this->translate('Successfully updated rule.')); + $ruleId = $form->storeInDatabase(Database::get(), $rule); + Notification::success(sprintf( + $this->translate('Successfully saved event rule %s'), + $form->getValue('name') + )); $this->sendExtraUpdates(['#col1']); $this->redirectNow(Links::eventRule($ruleId)); - })->on(SaveEventRuleForm::ON_REMOVE, function ($form) use ($ruleId) { - $form->removeRule($ruleId); - $this->sessionNamespace->delete($ruleId); - - Notification::success($this->translate('Successfully removed rule.')); - $this->redirectNow('__CLOSE__'); - })->handleRequest($this->getServerRequest()); - - $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $eventRuleConfig->getConfig()['name'] ?? ''), - (new Link( - new Icon('edit'), - Url::fromPath('notifications/event-rule/edit', [ - 'id' => $ruleId - ]), - ['class' => 'control-button'] - ))->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->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(t('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; - } + }) + ->on(Form::ON_SENT, function (EventRuleConfigForm $form) use ($ruleId) { + if ($form->hasBeenRemoved()) { + $form->removeRule(Database::get(), $this->fetchRule($ruleId)); + Notification::success(sprintf( + $this->translate('Successfully deleted event rule %s'), + $form->getValue('name') + )); + $this->switchToSingleColumnLayout(); + } + }) + ->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'))); + } + + $this->getResponse()->setHeader('X-Icinga-Location-Query', $this->params->toString()); + } elseif ($ruleId !== -1) { + $rule = $this->fetchRule($ruleId); + + $form->load($rule); + + $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]); - $escalationRecipients = $re - ->rule_escalation_recipient - ->withoutColumns(['changed_at', 'deleted']); + $this->session->set('name', $name); + $this->session->set('object_filter', ''); + } + }) + ->handleRequest($this->getServerRequest()); - foreach ($escalationRecipients as $recipient) { - $config[$re->getTableName()][$re->position]['recipient'][] = iterator_to_array($recipient); - } + if ($multiPartUpdate) { + return; } - $config['showSearchbar'] = ! empty($config['object_filter']); + $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() + ) + ])); - return $config; + $this->addContent($eventRuleConfig); } /** @@ -182,7 +162,6 @@ public function completeAction(): void $this->getDocument()->add($suggestions); } - /** * searchEditorAction for Object Extra Tags * @@ -192,84 +171,72 @@ public function completeAction(): void */ public function searchEditorAction(): void { - $ruleId = $this->params->shiftRequired('id'); + $ruleId = (int) $this->params->getRequired('id'); - $eventRule = $this->sessionNamespace->get($ruleId) ?? $this->fromDb($ruleId); + $editor = new SearchEditor(); - $editor = EventRuleConfig::createSearchEditor() - ->setQueryString($eventRule['object_filter'] ?? ''); + $editor->setQueryString($this->session->get('object_filter')) + ->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'] = EventRuleConfig::createFilterString($form->getFilter()); + $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->sessionNamespace->set($ruleId, $eventRule); - $this->getResponse() - ->setHeader('X-Icinga-Container', '_self') - ->redirectAndExit( - Url::fromPath( - 'notifications/event-rule', - ['id' => $ruleId] - ) - ); + $this->session->set('object_filter', $filter); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); }); $editor->handleRequest($this->getServerRequest()); - $this->getDocument()->add($editor); + $this->getDocument()->addHtml($editor); $this->setTitle($this->translate('Adjust Filter')); } 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); - } + $ruleId = (int) $this->params->getRequired('id'); $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, $cache, $config) { - $config['name'] = $form->getValue('name'); + ->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()); - if ($cache || $ruleId === '-1') { - $this->sessionNamespace->set($ruleId, $config); - } else { - (new SaveEventRuleForm())->editRule((int) $ruleId, $config); - } + $this->setTitle($this->translate('Edit Event Rule')); - if ($ruleId === '-1') { - $redirectUrl = Url::fromPath('notifications/event-rules/add', [ - 'use_cache' => true - ]); - } else { - $redirectUrl = Url::fromPath('notifications/event-rule', [ - 'id' => $ruleId - ]); - $this->sendExtraUpdates(['#col1']); - } + $this->addContent($eventRuleForm); + } - $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); - $this->redirectNow($redirectUrl); - })->handleRequest($this->getServerRequest()); + /** + * 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)); - if ($ruleId === '-1') { - $this->setTitle($this->translate('New Event Rule')); - } else { - $this->setTitle($this->translate('Edit Event Rule')); + /* @var ?Rule $rule */ + $rule = $query->first(); + if ($rule === null) { + $this->httpNotFound(t('Rule not found')); } - $this->addContent($eventRuleForm); + return $rule; } } diff --git a/application/controllers/EventRulesController.php b/application/controllers/EventRulesController.php index d7c294b2d..5fbabb029 100644 --- a/application/controllers/EventRulesController.php +++ b/application/controllers/EventRulesController.php @@ -6,42 +6,29 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; -use Icinga\Module\Notifications\Forms\SaveEventRuleForm; +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\EventRuleConfig; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; -use Icinga\Web\Notification; use Icinga\Web\Session; -use ipl\Html\Html; -use ipl\Stdlib\Filter; +use ipl\Html\Form; 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; 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; - public function init() { $this->assertPermission('notifications/config/event-rules'); - $this->sessionNamespace = Session::getSession()->getNamespace('notifications'); } public function indexAction(): void @@ -53,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') ] ); @@ -68,7 +55,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(); @@ -84,13 +71,14 @@ public function indexAction(): void $this->addControl($sortControl); $this->addControl($limitControl); $this->addControl($searchBar); + $this->addContent( (new ButtonLink( - t('New Event Rule'), - Url::fromPath('notifications/event-rule/edit', ['id' => -1, 'clearCache' => true]), - 'plus' + $this->translate('Add Event Rule'), + Url::fromPath('notifications/event-rules/add'), + 'plus', + ['class' => 'add-new-component'] ))->openInModal() - ->addAttributes(['class' => 'add-new-component']) ); $this->addContent( @@ -108,69 +96,19 @@ public function indexAction(): void public function addAction(): void { - $this->addTitleTab(t('Add Event Rule')); - $this->getTabs()->setRefreshUrl(Url::fromPath('notifications/event-rules/add')); - - $this->controls->addAttributes(['class' => 'event-rule-detail']); - - if ($this->params->has('use_cache') || $this->getServerRequest()->getMethod() !== 'GET') { - $cache = $this->sessionNamespace->get(-1, []); - } else { - $this->sessionNamespace->delete(-1); - - $cache = []; - } - - $eventRuleConfig = new EventRuleConfig(Url::fromPath('notifications/event-rules/add-search-editor'), $cache); + $this->setTitle($this->translate('Add Event Rule')); - $eventRuleForm = Html::tag('div', ['class' => 'event-rule-form'], [ - Html::tag('h2', $eventRuleConfig->getConfig()['name'] ?? ''), - (new Link( - new Icon('edit'), - Url::fromPath('notifications/event-rule/edit', [ - 'id' => -1 - ]), - ['class' => 'control-button'] - ))->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.')); + $eventRuleForm = (new EventRuleForm()) + ->populate(['id' => -1]) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(Form::ON_SUCCESS, function ($form) { $this->sendExtraUpdates(['#col1']); - $this->redirectNow(Links::eventRule($id)); + $this->getResponse()->setHeader('X-Icinga-Container', 'col2'); + $this->redirectNow(Links::eventRule(-1)->addParams(['name' => $form->getValue('name')])); })->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->addContent($eventRuleConfig); + $this->addContent($eventRuleForm); } public function completeAction(): void @@ -195,49 +133,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 - * - * @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/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/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/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 new file mode 100644 index 000000000..34da1ac20 --- /dev/null +++ b/application/forms/EventRuleConfigForm.php @@ -0,0 +1,459 @@ + ['event-rule-config', 'icinga-controls'], + 'name' => 'event-rule-config-form', + 'id' => 'event-rule-config-form' + ]; + + /** @var ConfigProviderInterface */ + protected ConfigProviderInterface $configProvider; + + /** @var Url Search editor URL for the config filter fieldset */ + protected Url $searchEditorUrl; + + /** + * Create a new EventRuleConfigForm + * + * @param ConfigProviderInterface $configProvider + * @param Url $searchEditorUrl + */ + public function __construct(ConfigProviderInterface $configProvider, Url $searchEditorUrl) + { + $this->configProvider = $configProvider; + $this->searchEditorUrl = $searchEditorUrl; + } + + public function hasBeenSubmitted(): bool + { + $pressedButton = $this->getPressedSubmitElement(); + + if ($pressedButton && $pressedButton->getName() === 'save') { + return true; + } + + return false; + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(); + + // Replicate the save button outside the form + $this->addElement( + 'submitButton', + 'save', + [ + 'hidden' => true, + 'class' => 'primary-submit-btn-duplicate' + ] + ); + + // Replicate the delete button outside the form + $this->addElement( + 'submitButton', + 'delete', + [ + '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'])) + ); + + $escalations = new EventRuleConfigElements\Escalations('escalations', [ + 'provider' => $this->configProvider, + 'required' => true + ]); + $this->addElement($escalations); + + $this->addElement('hidden', 'id', ['required' => true]); + + $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 + )); + } + + /** + * 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'), + 'formnovalidate' => true, + 'title' => $this->translate('Add filter') + ]); + $this->registerElement($addFilterButton); + + if ($addFilterButton->hasBeenPressed()) { + $this->remove($addFilterButton); // De-register the button + } else { + $hiddenInput = $this->createElement('hidden', 'object_filter'); + $this->registerElement($hiddenInput); + + $objectFilter = new HtmlElement( + 'div', + Attributes::create(['class' => 'add-button-wrapper']), + $addFilterButton, + $hiddenInput + ); + + $hasFilter = false; + } + } + + 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 + ]) + ); + + $objectFilter = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-controls']), + $objectFilter, + $editorOpener + ); + } + + return $objectFilter; + } + + /** + * 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(['id' => 'event-rule-config-form-name']), + $this->createElement('hidden', 'name', ['required' => true, 'value' => $newName]) + ); + } + + /** + * Get the element to update in case the object filter of the rule is changed + * + * @param string $newFilter + * + * @return ValidHtml + */ + public function prepareObjectFilterUpdate(string $newFilter): ValidHtml + { + $this->populate(['object_filter' => $newFilter]); + + return new HtmlElement( + 'div', + Attributes::create(['id' => 'object-filter-controls']), + $this->createObjectFilterControls() + ); + } + + /** + * Create and return the submit-buttons for the form + * + * @return SubmitButtonElement[] + */ + public function createExternalSubmitButtons(): array + { + $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 + ]); + } + + $buttons[] = $this->createElement('submitButton', 'save', [ + 'data-progress-label' => $this->translate('Saving rule'), + 'label' => $this->translate('Save'), + 'form' => 'event-rule-config-form' + ]); + + return $buttons; + } + + /** + * Load the given event rule into the form + * + * @param Rule $rule + * + * @return void + */ + public function load(Rule $rule): void + { + $this->populate([ + 'id' => $rule->id, + 'name' => $rule->name, + 'object_filter' => $rule->object_filter, + 'escalations' => EventRuleConfigElements\Escalations::prepare( + $rule->rule_escalation->orderBy('position', 'asc') + ) + ]); + } + + /** + * Check whether the name or object filter changed according to the given previous rule + * + * @param Rule $previousRule + * + * @return bool + */ + protected function hasChanged(Rule $previousRule): bool + { + if ($previousRule->name !== $this->getValue('name')) { + return true; + } + + if ($previousRule->object_filter !== $this->getValue('object_filter')) { + return true; + } + + return false; + } + + /** + * Insert to or update event rule in the database and return the id of the event rule + * + * @param Connection $db + * @param ?Rule $previousRule + * + * @return int + */ + public function storeInDatabase(Connection $db, ?Rule $previousRule): int + { + $db->beginTransaction(); + + $ruleId = (int) $this->getValue('id'); + if ($previousRule === null) { + $db->insert('rule', [ + 'name' => $this->getValue('name'), + 'timeperiod_id' => null, + 'object_filter' => $this->getValue('object_filter'), + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'n' + ]); + + $ruleId = (int) $db->lastInsertId(); + } elseif ($this->hasChanged($previousRule)) { + $db->update('rule', [ + 'name' => $this->getValue('name'), + 'object_filter' => $this->getValue('object_filter'), + 'changed_at' => (int) (new DateTime())->format("Uv") + ], ['id = ?' => $ruleId]); + } + + $escalationsFromDb = []; + foreach ($previousRule?->rule_escalation ?? [] as $escalationFromDb) { + /** @var RuleEscalation $escalationFromDb */ + $escalationsFromDb[$escalationFromDb->id] = $escalationFromDb; + } + + $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' + ]); + + $recipients[(int) $db->lastInsertId()] = [$escalation->getRecipients(), []]; + } else { + $escalationFromDb = $escalationsFromDb[$config['id']]; + + $recipientsFromDb = []; + foreach ($escalationFromDb->rule_escalation_recipient as $recipientFromDb) { + $recipientsFromDb[$recipientFromDb->id] = $recipientFromDb; + } + + $recipients[(int) $config['id']] = [$escalation->getRecipients(), $recipientsFromDb]; + + 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($escalationsFromDb[$config['id']]); + } + } + + // 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"), + 'deleted' => 'y' + ], ['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]); + } + + 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']]); + } + + unset($recipientsFromDb[$config['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']); + } + } + + $db->commitTransaction(); + + return $ruleId; + } + + /** + * Get whether the delete button was pressed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf->isValid() && $btn !== null && $btn->getName() === 'delete'; + } + + /** + * Remove the given event rule + * + * @param Connection $db + * @param Rule $rule + * + * @return void + */ + public function removeRule(Connection $db, Rule $rule): void + { + $db->beginTransaction(); + + $escalationsToRemove = []; + /** @var RuleEscalation $escalation */ + foreach ($rule->rule_escalation 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 = ?' => $rule->id]); + $db->update('rule', [ + 'changed_at' => (int) (new DateTime())->format("Uv"), + 'deleted' => 'y' + ], ['id = ?' => $rule->id]); + + $db->commitTransaction(); + } +} diff --git a/application/forms/EventRuleForm.php b/application/forms/EventRuleForm.php index 54d45b52a..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; @@ -14,9 +13,9 @@ class EventRuleForm extends CompatForm use CsrfCounterMeasure; use Translation; - protected function assemble() + protected function assemble(): void { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(); $this->addElement( 'text', 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/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/public/css/detail/event-rule-detail.less b/public/css/detail/event-rule-detail.less index 18ca41784..22a061cf0 100644 --- a/public/css/detail/event-rule-detail.less +++ b/public/css/detail/event-rule-detail.less @@ -1,432 +1,269 @@ -.event-rule-detail { - display: flex; - align-items: baseline; +.event-rule-config { + @connectorHeight: .5em; + @connectorColor: @gray-lighter; - > .right-arrow:first-child { - margin-top: 3.125em; - } + // Style - &.invalid { - .escalations .escalation form.escalation-form { - select, - input { - &:invalid { - background-color: red; - } - } - } + .connector-line { + background-color: @connectorColor; } - .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-controls, + .dynamic-list:not(.empty, .escalations) { + padding: .5em; + border: 1px solid @connectorColor; + .rounded-corners(); } - .escalations { - display: inline-flex; - flex-direction: column; - width: 70em; - - .vertical-line { - position: absolute; - z-index: -1; - top: 15%; - bottom: 0; - margin-left: 1.25em; - } + .add-button, + .remove-button { + .event-rule-button(); - > .escalation { - display: flex; - align-items: center; - padding-bottom: 2em; - position: relative; + &.animated.active { + .fa::before { + .animate(spin 2s infinite linear); - &:before { - content: ""; - display: block; - position: absolute; - top: 0; - bottom: 0; - width: .5em; - margin-left: 1.25em; - background: @gray-lighter; - z-index: -1; + // fa spinner + content: '\f110'; } + } + } - &:first-child:before { - content: ""; - display: block; - top: calc(~"50% - 1em"); - } + .remove-button-disabled { + .event-rule-button(true); + } - .right-arrow:first-child { - width: 2em; - } + .escalation-condition, + .escalation-recipient { + > :first-child, + > :first-child[type="hidden"] + * { + .rounded-corners(.25em); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } - .right-arrow-long { - width: 38em; - } + > :last-child { + .rounded-corners(.25em); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } - .right-arrow.right-arrow-long:first-child { - width: 47em; - } + > :not(:first-child, :first-child[type="hidden"] + *, :last-child) { + .rounded-corners(0); - .right-arrow-one-escalation:first-child { - width: 15em; + * { + // nested inputs as well + .rounded-corners(0); } + } + } - .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; - } + // Layout - .right-operand { - border-radius: 0 0.4em 0.4em 0; - width: 0; - flex: 1 1 auto; - margin-left: 1px; - } + display: flex; - .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; + .connector-line { + height: @connectorHeight; + margin-top: 2.75em; + } - option { text-align: center; } - } - } + > .connector-line { + flex: 1 1 auto; + min-width: 1em; + } - .remove-button { - height: 2.25em; - margin-left: 0.5em; - } - } + #object-filter-controls { + width: 20em; + height: fit-content; - .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; - } - } - } + .filter-controls { + margin-top: 1.25em; + display: flex; + gap: .5em; - .escalation-condition-form.count-zero-escalation-condition-form:after { - content: 'Condition'; - align-self: center; - margin-bottom: -1.5em; - color: @text-color-light; + input[type="text"] { + flex: 1 1 auto; + width: 0; } } } -} -.escalations-with-add-form { - .add-escalation-form { - position: relative; - margin-left: -0.1em; + .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; } -} -.cache-notice { - margin: 1em; - padding: 1em; - background-color: @gray-lighter; - text-align: center; - .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; + .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; + } - &:hover, - &:focus { - color: @icinga-blue; + .add-button { + flex: 0; + } } - &:hover { - background: @low-sat-blue-dark; + > #object-filter-controls > .add-button-wrapper { + min-width: 12em; } - &:focus { - outline: 3px solid fade(@icinga-blue, 50%); - outline-offset: 1px; + > .escalations { + flex: 15 1 auto; } -} -.escalation-form { - display: flex; - flex-direction: column; + .escalations { + display: grid; + grid-template-columns: min-content minmax(1em, 4em) 1fr minmax(1em, 4em) 1fr; - .options +.add-button, - .remove-button { - .event-rule-button(); - } + .escalation { + display: contents; + } - .options + .add-button { - margin-right: 3.5em; - } + .escalation > div:first-child { + .vertical-line(); - .options li { - input, select { - &:last-child:not(.remove-button) { - margin-right: 3.5em; + .remove-button { + margin-top: 2em; } } - } -} -.add-filter-form, -.escalation-form.count-zero-escalation-condition-form { - button[type="submit"] { - font-size: 2em; - height: 2.25em; - margin: 0; + // 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(); - > .icon { - flex-wrap: wrap; - align-content: flex-start; + .add-button { + margin-top: 2em; + } } - .event-rule-button(); - } -} + .escalation .dynamic-list { + position: relative; + margin-bottom: 1em; -.remove-escalation-form button[type="submit"], -.add-escalation-form button[type="submit"] { - .event-rule-button(); -} + .dynamic-item { + display: grid; + align-items: baseline; + margin-bottom: .5em; + gap: 1px; -.right-arrow, -.horizontal-line { - display: inline-block; - background-color: @base-gray-lighter; - height: 0.5em; - text-align: end; -} + &.escalation-condition { + grid-template-columns: minmax(8em, 1fr) 0fr 4em minmax(8em, 1fr) min-content; -.right-arrow { - width: 10em; - min-width: 2em; - margin-right: 0.4em; - position: relative; -} + .age-inputs { + display: flex; + gap: 1px; -.horizontal-line { - width: 3em; - min-width: 1em; -} + > * { + flex: 1 1 auto; + width: 0; + } + } + } -.right-arrow:after { - content: ''; - position: absolute; - border: 0.3em solid transparent; - border-left: 0.4em solid @base-gray-lighter; -} + &.escalation-recipient { + grid-template-columns: minmax(8em, 1fr) minmax(8em, 1fr) min-content; + } -.vertical-line { - width: 0.7em; - background-color: @base-gray-lighter; -} + input[type="text"], + .remove-button, + .remove-button-disabled { + height: 100%; + } + } -.remove-escalation-form { - width: fit-content; -} + .add-button { + width: 100%; -#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; -} + > .icon { + margin: 0 auto; + } + } + } + } -.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; + 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; } -} -#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) { - width: fit-content; -} + .vertical-line() { + position: relative; -.add-filter-form { - text-align: center; - width: auto; - position: relative; - bottom: calc(~"-.5em - 1px"); -} + &::before { + position: absolute; + z-index: -1; + @halfedConnectorHeight: @connectorHeight / 2; + inset: 0 ~"calc(50% - @{halfedConnectorHeight})" 0 ~"calc(50% - @{halfedConnectorHeight})"; -.add-filter-form:after { - content: 'Filter'; - display: block; - color: @text-color-light; -} + content: ""; + width: @connectorHeight; -.horizontal-line-long { - width: 14.5em; + background-color: @connectorColor; + } + } } -.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; +#layout.twocols:not(.wide-layout) { + .event-rule-config { + #object-filter-controls { + width: fit-content; - :last-child { - float: right; + input[type="text"] { + display: none; } + } - .control-label-group { - width: auto; - } + .dynamic-list.empty .add-button, + > #object-filter-controls > .add-button-wrapper .add-button { + padding: .5em 1em; + margin-top: 2em; + } - input[type='text'] { - max-width: unset; - width: 25em; - } + > #object-filter-controls > .add-button-wrapper { + min-width: fit-content; } } +} - .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; - } +// Other stuff - &.btn-remove { - border: none; +.event-rule-and-save-forms { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: .5em; - &:disabled { - background: none; - cursor: not-allowed; - opacity: 0.5; - } - } + .event-rule-form { + display: flex; + gap: .5em; - &.btn-discard-changes { - .event-rule-button(); - } + h2 { + margin: 0; } } -} -.remove-escalation-form { - button[disabled] { - &:disabled { - background: @gray-light; - color: @disabled-gray; - cursor: not-allowed; + #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/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 +} 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); + } +}