diff --git a/application/controllers/ChannelsController.php b/application/controllers/ChannelsController.php index 33362594c..43fa9c781 100644 --- a/application/controllers/ChannelsController.php +++ b/application/controllers/ChannelsController.php @@ -118,7 +118,7 @@ public function addAction(): void $form->getValue('name') ) ); - $this->redirectNow(Links::channels()); + $this->switchToSingleColumnLayout(); }) ->handleRequest($this->getServerRequest()); 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/application/controllers/ContactsController.php b/application/controllers/ContactsController.php index 8a28fad74..f6ec70bfb 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\Contract\Form; +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 @@ -48,8 +53,8 @@ public function indexAction() $sortControl = $this->createSortControl( $contacts, [ - 'full_name' => t('Full Name'), - 'changed_at' => t('Changed At') + 'full_name' => $this->translate('Full Name'), + 'changed_at' => $this->translate('Changed At') ] ); @@ -79,15 +84,32 @@ 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( + $this->translate('Add Contact'), + Links::contactAdd(), + 'plus', + ['class' => 'add-new-component'] + ))->setBaseTarget('_next'); + + $emptyStateMessage = null; + if (Channel::on($this->db)->columns([new Expression('1')])->limit(1)->first() === null) { + $addButton->disable($this->translate('A channel is required to add a contact')); + + $emptyStateMessage = 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()))->setBaseTarget('_next')] + ); + } + + $this->addContent($addButton); $this->addContent( (new ObjectList($contacts, new ContactRenderer())) ->setItemLayoutClass(MinimalItemLayout::class) + ->setEmptyStateMessage($emptyStateMessage) ); if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { @@ -102,12 +124,12 @@ public function indexAction() public function addAction(): void { - $this->addTitleTab(t('Add Contact')); + $this->addTitleTab($this->translate('Add Contact')); $form = (new ContactForm($this->db)) - ->on(ContactForm::ON_SUCCESS, function (ContactForm $form) { + ->on(Form::ON_SUBMIT, function (ContactForm $form) { $form->addContact(); - Notification::success(t('New contact has successfully been added')); + Notification::success($this->translate('New contact has successfully been added')); $this->redirectNow(Links::contacts()); })->handleRequest($this->getServerRequest()); diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index cc4f81036..7c6a3e9cb 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -11,7 +11,6 @@ use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; -use ipl\Sql\Connection; use ipl\Web\Widget\Icon; /** @@ -107,24 +106,4 @@ public function getIcon(): Icon return $icon; } - - /** - * Fetch and map all the configured channel names to a key => value array - * - * @param Connection $conn - * - * @return string[] All the channel names mapped as id => name - */ - public static function fetchChannelNames(Connection $conn): array - { - $channels = []; - $query = Channel::on($conn); - /** @var Channel $channel */ - foreach ($query as $channel) { - $name = $channel->name; - $channels[$channel->id] = $name; - } - - return $channels; - } } diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 94f0639a9..85ba102cd 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; @@ -22,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 { @@ -71,7 +76,8 @@ public function isValidEvent($event) protected function assemble() { - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + $this->addAttributes(['class' => 'contact-form']); + $this->addCsrfCounterMeasure(Session::getSession()->getId()); // Fieldset for contact full name and username $contact = (new FieldsetElement( @@ -83,61 +89,106 @@ protected function assemble() $this->addElement($contact); - $channelOptions = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $channelOptions += Channel::fetchChannelNames($this->db); - $contact->addElement( 'text', 'full_name', [ - 'label' => $this->translate('Full Name'), + 'label' => $this->translate('Contact Name'), 'required' => true ] - )->addElement( - 'text', - 'username', - [ - 'label' => $this->translate('Username'), - '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; - }) - ] - ] - )->addElement( + ); + + $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( + 'Use this to associate actions in the UI, such as incident management, with this contact.' + . ' To successfully receive desktop notifications, this is also required.' + )) + ) + ); + + $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' => $channelOptions + 'options' => [ + '' => sprintf(' - %s - ', $this->translate('Please choose')) + ] + $channelNames, ] ); - $this->addAddressElements(); + $contact->registerElement($defaultChannel); + + $this->addAddressElements($channelTypes[$defaultChannel->getValue()] ?? null); + + $this->addHtml(new HtmlElement('hr')); + + $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 an event rule." + )) + )); $this->addElement( 'submit', 'submit', [ 'label' => $this->contactId === null ? - $this->translate('Add Contact') : + $this->translate('Create Contact') : $this->translate('Save Changes') ] ); @@ -147,7 +198,7 @@ protected function assemble() 'submit', 'delete', [ - 'label' => $this->translate('Delete'), + 'label' => $this->translate('Delete Contact'), 'class' => 'btn-remove', 'formnovalidate' => true ] @@ -380,7 +431,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 @@ -399,9 +450,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) @@ -413,13 +466,20 @@ private function addAddressElements(): void return; } - $address = new FieldsetElement('contact_address', ['label' => $this->translate('Addresses')]); + $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) { + 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') { diff --git a/public/css/form.less b/public/css/form.less index f72f5eda3..de5e13163 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -19,6 +19,10 @@ input[type="date"] { width: auto; } + + fieldset p.description:last-child { // fieldset > .control-group:last-of-type unset the last element's bottom margin + margin-top: 1em; + } } .rotation-config { @@ -86,6 +90,12 @@ /* Style */ +.icinga-controls { + p.description { + color: @text-color-light; + } +} + .rotation-mode { --img-24-7: url('../img/notifications/pictogram/24-7-dark.jpg'); --img-partial: url("../img/notifications/pictogram/partial-dark.jpg"); @@ -171,3 +181,10 @@ border-color: transparent; } } + +.contact-form { + hr { + border: none; + border-top: 1px solid var(--gray-light, @gray-light); + } +}