From 57953110b5d7611fcb07906548e2c475f9702d05 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 20 Feb 2025 12:48:53 +0100 Subject: [PATCH] ContactForm: Show suggestions while typing in `user` element Introduce IcingaWebUserSuggestions class ContactControler: Add suggestion action and remove dead code --- application/controllers/ContactController.php | 13 +- .../Notifications/Web/Form/ContactForm.php | 79 +++++---- .../Widget/IcingaWebUserSuggestions.php | 158 ++++++++++++++++++ public/css/form.less | 8 + 4 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 library/Notifications/Widget/IcingaWebUserSuggestions.php diff --git a/application/controllers/ContactController.php b/application/controllers/ContactController.php index 0fc7b429..d5ff8311 100644 --- a/application/controllers/ContactController.php +++ b/application/controllers/ContactController.php @@ -5,12 +5,9 @@ namespace Icinga\Module\Notifications\Controllers; use Icinga\Module\Notifications\Common\Database; -use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Web\Form\ContactForm; +use Icinga\Module\Notifications\Widget\IcingaWebUserSuggestions; use Icinga\Web\Notification; -use ipl\Html\FormElement\FieldsetElement; -use ipl\Sql\Connection; -use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; class ContactController extends CompatController @@ -48,4 +45,12 @@ public function indexAction(): void $this->addContent($form); } + + public function suggestIcingaWebUserAction(): void + { + $users = new IcingaWebUserSuggestions(); + $users->forRequest($this->getServerRequest()); + + $this->getDocument()->addHtml($users); + } } diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 7e6f6582..7a13a1bf 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -24,6 +24,7 @@ use ipl\Validator\StringLengthValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; class ContactForm extends CompatForm { @@ -93,40 +94,52 @@ 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; - }) + ); + + $suggestionsId = 'icinga-user-suggestions'; + $contact + ->addHtml(new HtmlElement('div', new Attributes(['id' => $suggestionsId, 'class' => 'search-suggestions']))) + ->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; + }) + ], + 'placeholder' => $this->translate('Start typing to see suggestions ...'), + 'autocomplete' => 'off', + 'class' => 'search', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-suggest-url' => Url::fromPath('notifications/contact/suggest-icinga-web-user') + ->with(['showCompact' => true, '_disableLayout' => 1]), ] - ] - )->addHtml(new HtmlElement( - 'p', - new Attributes(['class' => 'description']), - new Text($this->translate( - "Link existing Icinga Web users. Users from external authentication backends" - . " won't be suggested and must be entered manually." - )) - )); + )->addHtml(new HtmlElement( + 'p', + new Attributes(['class' => 'description']), + new Text($this->translate( + "Link existing Icinga Web users. Users from external authentication backends" + . " won't be suggested and must be entered manually." + )) + )); $defaultChannel = $this->createElement( 'select', diff --git a/library/Notifications/Widget/IcingaWebUserSuggestions.php b/library/Notifications/Widget/IcingaWebUserSuggestions.php new file mode 100644 index 00000000..fb8e8412 --- /dev/null +++ b/library/Notifications/Widget/IcingaWebUserSuggestions.php @@ -0,0 +1,158 @@ +searchTerm = $term; + + return $this; + } + + public function setOriginalValue(string $term): self + { + $this->originalValue = $term; + + return $this; + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request): self + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $this->setSearchTerm($requestData['term']['label']); + $this->setOriginalValue($requestData['term']['search']); + + return $this; + } + + protected function assemble(): void + { + $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', $this->searchTerm) + ->limit($limit); + + try { + $names = $query->fetchColumn(); + } catch (Exception $e) { + continue; + } + + if (empty($names)) { + continue; + } + + if ($backend instanceof DomainAwareInterface) { + $names = array_map(function ($name) use ($backend) { + return $name . '@' . $backend->getDomain(); + }, $names); + } + + $this->addHtml( + new HtmlElement( + 'li', + new Attributes(['class' => Suggestions::SUGGESTION_TITLE_CLASS]), + new Text($this->translate('Backend')), + new HtmlElement('span', new Attributes(['class' => 'badge']), new Text($backend->getName())) + ) + ); + + foreach ($names as $name) { + $this->addHtml( + new HtmlElement( + 'li', + null, + new HtmlElement( + 'input', + Attributes::create([ + 'type' => 'button', + 'value' => $name, + 'data-label' => $name, + 'data-search' => $name, + 'data-class' => 'icinga-web-user', + ]) + ) + ) + ); + } + + $limit -= count($names); + } + + if ($this->isEmpty()) { + $this->addHtml( + new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create($this->translate('Nothing to suggest'))) + ) + ); + } + } + + public function renderUnwrapped(): string + { + $this->ensureAssembled(); + + if ($this->isEmpty()) { + return ''; + } + + return parent::renderUnwrapped(); + } +} diff --git a/public/css/form.less b/public/css/form.less index 9abdcb00..3db3bb5f 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -122,4 +122,12 @@ padding-bottom: 1em; border-bottom: 1px solid @gray-light; } + + input.search { + padding-left: 1.5em; // property was overwritten .icinga-controls + } + + .search-suggestions .badge { + margin-left: 0.5em; + } }