From 0d9801ebfb3b43aaa7a06e2cff567b671c6d1bcb Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 17 Feb 2025 08:39:52 +0100 Subject: [PATCH 01/10] ContactForm: Improve labels of form elements --- library/Notifications/Web/Form/ContactForm.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 94f0639a9..981b3d238 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -90,14 +90,14 @@ protected function assemble() 'text', 'full_name', [ - 'label' => $this->translate('Full Name'), + 'label' => $this->translate('Contact Name'), 'required' => true ] )->addElement( 'text', 'username', [ - 'label' => $this->translate('Username'), + 'label' => $this->translate('Icinga Web User'), 'validators' => [ new StringLengthValidator(['max' => 254]), new CallbackValidator(function ($value, $validator) { @@ -137,7 +137,7 @@ protected function assemble() 'submit', [ 'label' => $this->contactId === null ? - $this->translate('Add Contact') : + $this->translate('Create Contact') : $this->translate('Save Changes') ] ); @@ -147,7 +147,7 @@ protected function assemble() 'submit', 'delete', [ - 'label' => $this->translate('Delete'), + 'label' => $this->translate('Delete Contact'), 'class' => 'btn-remove', 'formnovalidate' => true ] @@ -413,7 +413,7 @@ private function addAddressElements(): void return; } - $address = new FieldsetElement('contact_address', ['label' => $this->translate('Addresses')]); + $address = new FieldsetElement('contact_address', ['label' => $this->translate('Channels')]); $this->addElement($address); foreach ($plugins as $type => $label) { From e4889795fab1c1d49ecb0ada2e9b71141dffad4b Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 17 Feb 2025 09:02:07 +0100 Subject: [PATCH 02/10] ContactForm: Add description to channels fieldset --- library/Notifications/Web/Form/ContactForm.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 981b3d238..c6e071790 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -13,8 +13,11 @@ use Icinga\Module\Notifications\Model\RotationMember; use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Web\Session; +use ipl\Html\Attributes; use ipl\Html\Contract\FormSubmitElement; use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; use ipl\Sql\Connection; use ipl\Stdlib\Filter; use ipl\Validator\CallbackValidator; @@ -119,7 +122,8 @@ protected function assemble() }) ] ] - )->addElement( + ) + ->addElement( 'select', 'default_channel_id', [ @@ -414,6 +418,12 @@ private function addAddressElements(): void } $address = new FieldsetElement('contact_address', ['label' => $this->translate('Channels')]); + $address->addHtml(new HtmlElement( + 'p', + new Attributes(['class' => 'description']), + new Text($this->translate('Configure the channels available for this contact here.')) + )); + $this->addElement($address); foreach ($plugins as $type => $label) { From 8372804f42360dfe4694e4fe08c147e0f4f684aa Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 17 Feb 2025 13:21:10 +0100 Subject: [PATCH 03/10] ContactFrom: Add `Default channel` element as last form element As the element `default_channel_id` is no longer part of the field set `contact`, it must be handled accordingly --- .../Notifications/Web/Form/ContactForm.php | 21 ++++++++++++------- public/css/form.less | 5 +++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index c6e071790..0a3eb8174 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -74,6 +74,7 @@ public function isValidEvent($event) protected function assemble() { + $this->addAttributes(['class' => 'contact-form']); $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); // Fieldset for contact full name and username @@ -86,9 +87,6 @@ protected function assemble() $this->addElement($contact); - $channelOptions = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $channelOptions += Channel::fetchChannelNames($this->db); - $contact->addElement( 'text', 'full_name', @@ -122,20 +120,29 @@ protected function assemble() }) ] ] - ) - ->addElement( + ); + + $defaultChannel = $this->createElement( 'select', 'default_channel_id', [ 'label' => $this->translate('Default Channel'), 'required' => true, 'disabledOptions' => [''], - 'options' => $channelOptions + 'options' => ['' => sprintf(' - %s - ', $this->translate('Please choose'))] + + Channel::fetchChannelNames($this->db), ] ); + $contact->registerElement($defaultChannel); + $this->addAddressElements(); + $this->addHtml(new HtmlElement('hr')); + + $this->decorate($defaultChannel); + $this->addHtml($defaultChannel); + $this->addElement( 'submit', 'submit', @@ -384,7 +391,7 @@ private function fetchDbValues(): array throw new HttpNotFoundException(t('Contact not found')); } - $values['contact'] = [ + $values['contact'] = [ 'full_name' => $contact->full_name, 'username' => $contact->username, 'default_channel_id' => (string) $contact->default_channel_id diff --git a/public/css/form.less b/public/css/form.less index f72f5eda3..492c42244 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -171,3 +171,8 @@ border-color: transparent; } } + +.contact-form hr { + border: none; + border-top: 1px solid @gray-light; +} From 44caf92731dd641c4aa7ceb755ad08fa28a0afe8 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 17 Feb 2025 19:56:47 +0100 Subject: [PATCH 04/10] ContactForm: Make address field of seleted default channel element `required` AvailableChannelType: Make channel a left join relation --- .../Notifications/Web/Form/ContactForm.php | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 0a3eb8174..1ec4dc050 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -122,21 +122,33 @@ protected function assemble() ] ); + $channelQuery = Channel::on($this->db) + ->columns(['id', 'name', 'type']); + + $channelNames = []; + $channelTypes = []; + foreach ($channelQuery as $channel) { + $channelNames[$channel->id] = $channel->name; + $channelTypes[$channel->id] = $channel->type; + } + $defaultChannel = $this->createElement( 'select', 'default_channel_id', [ 'label' => $this->translate('Default Channel'), 'required' => true, + 'class' => 'autosubmit', 'disabledOptions' => [''], - 'options' => ['' => sprintf(' - %s - ', $this->translate('Please choose'))] - + Channel::fetchChannelNames($this->db), + 'options' => [ + '' => sprintf(' - %s - ', $this->translate('Please choose')) + ] + $channelNames, ] ); $contact->registerElement($defaultChannel); - $this->addAddressElements(); + $this->addAddressElements($channelTypes[$defaultChannel->getValue()] ?? null); $this->addHtml(new HtmlElement('hr')); @@ -410,9 +422,11 @@ private function fetchDbValues(): array /** * Add address elements for all existing channel plugins * + * @param ?string $defaultType The selected default channel type + * * @return void */ - private function addAddressElements(): void + private function addAddressElements(?string $defaultType): void { $plugins = $this->db->fetchPairs( AvailableChannelType::on($this->db) @@ -433,10 +447,11 @@ private function addAddressElements(): void $this->addElement($address); - foreach ($plugins as $type => $label) { + foreach ($plugins as $type => $name) { $element = $this->createElement('text', $type, [ - 'label' => $label, - 'validators' => [new StringLengthValidator(['max' => 255])] + 'label' => $name, + 'validators' => [new StringLengthValidator(['max' => 255])], + 'required' => $type === $defaultType ]); if ($type === 'email') { From d2289fbf88e5af801efd49dd50bab672ebe30ca4 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 17 Feb 2025 20:17:13 +0100 Subject: [PATCH 05/10] ContactForm: Add description for `user` and `default_channel` element --- library/Notifications/Web/Form/ContactForm.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 1ec4dc050..27af7779c 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -120,7 +120,14 @@ protected function assemble() }) ] ] - ); + )->addHtml(new HtmlElement( + 'p', + new Attributes(['class' => 'description']), + new Text($this->translate( + 'Add an Icinga Web user to associate with this contact. Users from external authentication' + . " backends won't be suggested and must be entered manually." + )) + )); $channelQuery = Channel::on($this->db) ->columns(['id', 'name', 'type']); @@ -154,6 +161,14 @@ protected function assemble() $this->decorate($defaultChannel); $this->addHtml($defaultChannel); + $this->addHtml(new HtmlElement( + 'p', + new Attributes(['class' => 'description']), + new Text($this->translate( + "Contact will be notified via the default channel, when no specific channel is configured" + . " in a schedule or event rule." + )) + )); $this->addElement( 'submit', From 85d9cb0a0341074ff38394a7e30919a961ba094b Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 20 Feb 2025 12:48:53 +0100 Subject: [PATCH 06/10] ContactForm: Show suggestions while typing in `user` element ContactControler: Add suggestion action and cleanup code --- application/controllers/ContactController.php | 63 +++++++++++++++ .../Notifications/Web/Form/ContactForm.php | 81 +++++++++++-------- public/css/form.less | 8 +- 3 files changed, 115 insertions(+), 37 deletions(-) diff --git a/application/controllers/ContactController.php b/application/controllers/ContactController.php index 7b827dd65..7c8533824 100644 --- a/application/controllers/ContactController.php +++ b/application/controllers/ContactController.php @@ -4,10 +4,17 @@ namespace Icinga\Module\Notifications\Controllers; +use Exception; +use Icinga\Application\Config; +use Icinga\Authentication\User\DomainAwareInterface; +use Icinga\Authentication\User\UserBackend; +use Icinga\Data\Selectable; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Web\Form\ContactForm; +use Icinga\Repository\Repository; use Icinga\Web\Notification; use ipl\Web\Compat\CompatController; +use ipl\Web\FormElement\SearchSuggestions; class ContactController extends CompatController { @@ -44,4 +51,60 @@ public function indexAction(): void $this->addContent($form); } + + public function suggestIcingaWebUserAction(): void + { + $suggestions = new SearchSuggestions((function () use (&$suggestions) { + $userBackends = []; + foreach (Config::app('authentication') as $backendName => $backendConfig) { + $candidate = UserBackend::create($backendName, $backendConfig); + if ($candidate instanceof Selectable) { + $userBackends[] = $candidate; + } + } + + $limit = 10; + while ($limit > 0 && ! empty($userBackends)) { + /** @var Repository $backend */ + $backend = array_shift($userBackends); + $query = $backend->select() + ->from('user', ['user_name']) + ->where('user_name', $suggestions->getSearchTerm()) + ->limit($limit); + + try { + /** @var string[] $names */ + $names = $query->fetchColumn(); + } catch (Exception) { + continue; + } + + if (empty($names)) { + continue; + } + + $domain = null; + if ($backend instanceof DomainAwareInterface && $backend->getDomain()) { + $domain = '@' . $backend->getDomain(); + } + + foreach ($names as $name) { + yield [ + 'search' => $name . $domain, + 'label' => $name . $domain, + 'backend' => $backend->getName(), + ]; + } + + $limit -= count($names); + } + })()); + + $suggestions->setGroupingCallback(function (array $data) { + return $data['backend']; + }); + + $suggestions->forRequest($this->getServerRequest()); + $this->getDocument()->addHtml($suggestions); + } } diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 27af7779c..e160e3229 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -25,6 +25,8 @@ use ipl\Validator\StringLengthValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use ipl\Web\FormElement\SuggestionElement; +use ipl\Web\Url; class ContactForm extends CompatForm { @@ -94,40 +96,51 @@ protected function assemble() 'label' => $this->translate('Contact Name'), 'required' => true ] - )->addElement( - 'text', - 'username', - [ - 'label' => $this->translate('Icinga Web User'), - 'validators' => [ - new StringLengthValidator(['max' => 254]), - new CallbackValidator(function ($value, $validator) { - $contact = Contact::on($this->db) - ->filter(Filter::equal('username', $value)); - if ($this->contactId) { - $contact->filter(Filter::unequal('id', $this->contactId)); - } - - if ($contact->first() !== null) { - $validator->addMessage($this->translate( - 'A contact with the same username already exists.' - )); - - return false; - } - - return true; - }) - ] - ] - )->addHtml(new HtmlElement( - 'p', - new Attributes(['class' => 'description']), - new Text($this->translate( - 'Add an Icinga Web user to associate with this contact. Users from external authentication' - . " backends won't be suggested and must be entered manually." - )) - )); + ); + + $contact + ->addElement( + new SuggestionElement( + 'username', + Url::fromPath( + 'notifications/contact/suggest-icinga-web-user', + ['showCompact' => true, '_disableLayout' => 1] + ), + [ + 'label' => $this->translate('Icinga Web User'), + 'validators' => [ + new StringLengthValidator(['max' => 254]), + new CallbackValidator(function ($value, $validator) { + $contact = Contact::on($this->db) + ->filter(Filter::equal('username', $value)); + if ($this->contactId) { + $contact->filter(Filter::unequal('id', $this->contactId)); + } + + if ($contact->first() !== null) { + $validator->addMessage($this->translate( + 'A contact with the same username already exists.' + )); + + return false; + } + + return true; + }) + ] + ] + ) + ) + ->addHtml( + new HtmlElement( + 'p', + new Attributes(['class' => 'description']), + new Text($this->translate( + 'Add an Icinga Web user to associate with this contact. Users from external authentication' + . " backends won't be suggested and must be entered manually." + )) + ) + ); $channelQuery = Channel::on($this->db) ->columns(['id', 'name', 'type']); diff --git a/public/css/form.less b/public/css/form.less index 492c42244..039ad6d50 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -172,7 +172,9 @@ } } -.contact-form hr { - border: none; - border-top: 1px solid @gray-light; +.contact-form { + hr { + border: none; + border-top: 1px solid var(--gray-light, @gray-light); + } } From 9c292776ebb05ada56d593cc9d5fcdc659357469 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 20 Feb 2025 13:06:02 +0100 Subject: [PATCH 07/10] forms.less: Add `.description` color and margin --- public/css/form.less | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/public/css/form.less b/public/css/form.less index 039ad6d50..3101f47ba 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -178,3 +178,15 @@ border-top: 1px solid var(--gray-light, @gray-light); } } + +form { + p.description { + color: @text-color-light; + } + + fieldset p.description:last-child { // fieldset > .control-group:last-of-type unset the last element's bottom margin + margin-top: 1em; + } +} + + From 3e6234bf6adb6edc8a963412ed02fc24b29a13bd Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 15 May 2025 13:10:35 +0200 Subject: [PATCH 08/10] ContactsController: Show `add channel` link if no channel exists - ChannelsContoller: Closing the container is sufficient. --- .../controllers/ChannelsController.php | 2 +- .../controllers/ContactsController.php | 35 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/application/controllers/ChannelsController.php b/application/controllers/ChannelsController.php index 0858de438..c251e7180 100644 --- a/application/controllers/ChannelsController.php +++ b/application/controllers/ChannelsController.php @@ -112,7 +112,7 @@ public function addAction() $form->getValue('name') ) ); - $this->redirectNow(Links::channels()); + $this->switchToSingleColumnLayout(); }) ->handleRequest($this->getServerRequest()); diff --git a/application/controllers/ContactsController.php b/application/controllers/ContactsController.php index 8a28fad74..77207d416 100644 --- a/application/controllers/ContactsController.php +++ b/application/controllers/ContactsController.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Notifications\Controllers; use Icinga\Module\Notifications\Common\Links; +use Icinga\Module\Notifications\Model\Channel; use Icinga\Module\Notifications\View\ContactRenderer; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Notifications\Common\Database; @@ -12,7 +13,10 @@ use Icinga\Module\Notifications\Web\Form\ContactForm; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; use Icinga\Web\Notification; +use ipl\Html\HtmlElement; +use ipl\Html\TemplateString; use ipl\Sql\Connection; +use ipl\Sql\Expression; use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; @@ -20,6 +24,7 @@ use ipl\Web\Control\SortControl; use ipl\Web\Filter\QueryString; use ipl\Web\Layout\MinimalItemLayout; +use ipl\Web\Widget\ActionLink; use ipl\Web\Widget\ButtonLink; class ContactsController extends CompatController @@ -79,15 +84,35 @@ public function indexAction() $this->addControl($sortControl); $this->addControl($limitControl); $this->addControl($searchBar); - $this->addContent( - (new ButtonLink(t('Add Contact'), Links::contactAdd(), 'plus')) - ->setBaseTarget('_next') - ->addAttributes(['class' => 'add-new-component']) - ); + + $addButton = (new ButtonLink( + t('Add Contact'), + Links::contactAdd(), + 'plus', + ['class' => 'add-new-component'] + ))->setBaseTarget('_next'); + + $emptyStateMessage = null; + if (Channel::on($this->db)->columns([new Expression('1')])->first() === null) { + $addButton->disable(t('A channel is required to add a contact')); + + $emptyStateMessage = new HtmlElement( + 'span', + content: TemplateString::create( + $this->translate( + 'No contacts found. To add a new contact, please {{#link}}configure a Channel{{/link}} first.' + ), + ['link' => new ActionLink(null, Links::channelAdd(), attributes: ['data-base-target' => '_next'])] + ) + ); + } + + $this->addContent($addButton); $this->addContent( (new ObjectList($contacts, new ContactRenderer())) ->setItemLayoutClass(MinimalItemLayout::class) + ->setEmptyStateMessage($emptyStateMessage) ); if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { From fe8d14ddbaf042ddcad3ba9d00222c87249966ef Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 11 Aug 2025 11:01:56 +0200 Subject: [PATCH 09/10] COntactForm: Update description for user element --- library/Notifications/Web/Form/ContactForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index e160e3229..bcb720747 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -178,8 +178,8 @@ protected function assemble() 'p', new Attributes(['class' => 'description']), new Text($this->translate( - "Contact will be notified via the default channel, when no specific channel is configured" - . " in a schedule or event rule." + 'Use this to associate actions in the UI, such as incident management, with this contact.' + . ' To successfully receive desktop notifications, this is also required.' )) )); From d4c76d9d2a49c5bd70a398900a3edc08e55c8770 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Mon, 15 Sep 2025 17:29:05 +0200 Subject: [PATCH 10/10] ContactForm: Use method `addCsrfCounterMeasure()` --- library/Notifications/Web/Form/ContactForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index bcb720747..5ed2d3064 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -77,7 +77,7 @@ public function isValidEvent($event) protected function assemble() { $this->addAttributes(['class' => 'contact-form']); - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); // Fieldset for contact full name and username $contact = (new FieldsetElement(