Skip to content

Commit dcd99fe

Browse files
ContactForm: Show suggestions while typing in user element
Introduce IcingaWebUserSuggestions class ContactControler: Add suggestion action and remove dead code
1 parent 55c1722 commit dcd99fe

File tree

4 files changed

+221
-37
lines changed

4 files changed

+221
-37
lines changed

application/controllers/ContactController.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,9 @@
55
namespace Icinga\Module\Notifications\Controllers;
66

77
use Icinga\Module\Notifications\Common\Database;
8-
use Icinga\Module\Notifications\Model\Contact;
98
use Icinga\Module\Notifications\Web\Form\ContactForm;
9+
use Icinga\Module\Notifications\Widget\IcingaWebUserSuggestions;
1010
use Icinga\Web\Notification;
11-
use ipl\Html\FormElement\FieldsetElement;
12-
use ipl\Sql\Connection;
13-
use ipl\Stdlib\Filter;
1411
use ipl\Web\Compat\CompatController;
1512

1613
class ContactController extends CompatController
@@ -48,4 +45,12 @@ public function indexAction(): void
4845

4946
$this->addContent($form);
5047
}
48+
49+
public function suggestIcingaWebUserAction(): void
50+
{
51+
$users = new IcingaWebUserSuggestions();
52+
$users->forRequest($this->getServerRequest());
53+
54+
$this->getDocument()->addHtml($users);
55+
}
5156
}

library/Notifications/Web/Form/ContactForm.php

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ipl\Validator\StringLengthValidator;
2626
use ipl\Web\Common\CsrfCounterMeasure;
2727
use ipl\Web\Compat\CompatForm;
28+
use ipl\Web\Url;
2829

2930
class ContactForm extends CompatForm
3031
{
@@ -94,40 +95,52 @@ protected function assemble()
9495
'label' => $this->translate('Contact Name'),
9596
'required' => true
9697
]
97-
)->addElement(
98-
'text',
99-
'username',
100-
[
101-
'label' => $this->translate('Icinga Web User'),
102-
'validators' => [
103-
new StringLengthValidator(['max' => 254]),
104-
new CallbackValidator(function ($value, $validator) {
105-
$contact = Contact::on($this->db)
106-
->filter(Filter::equal('username', $value));
107-
if ($this->contactId) {
108-
$contact->filter(Filter::unequal('id', $this->contactId));
109-
}
110-
111-
if ($contact->first() !== null) {
112-
$validator->addMessage($this->translate(
113-
'A contact with the same username already exists.'
114-
));
115-
116-
return false;
117-
}
118-
119-
return true;
120-
})
98+
);
99+
100+
$suggestionsId = 'icinga-user-suggestions';
101+
$contact
102+
->addHtml(new HtmlElement('div', new Attributes(['id' => $suggestionsId, 'class' => 'search-suggestions'])))
103+
->addElement(
104+
'text',
105+
'username',
106+
[
107+
'label' => $this->translate('Icinga Web User'),
108+
'validators' => [
109+
new StringLengthValidator(['max' => 254]),
110+
new CallbackValidator(function ($value, $validator) {
111+
$contact = Contact::on($this->db)
112+
->filter(Filter::equal('username', $value));
113+
if ($this->contactId) {
114+
$contact->filter(Filter::unequal('id', $this->contactId));
115+
}
116+
117+
if ($contact->first() !== null) {
118+
$validator->addMessage($this->translate(
119+
'A contact with the same username already exists.'
120+
));
121+
122+
return false;
123+
}
124+
125+
return true;
126+
})
127+
],
128+
'placeholder' => $this->translate('Start typing to see suggestions ...'),
129+
'autocomplete' => 'off',
130+
'class' => 'search',
131+
'data-enrichment-type' => 'completion',
132+
'data-term-suggestions' => '#' . $suggestionsId,
133+
'data-suggest-url' => Url::fromPath('notifications/contact/suggest-icinga-web-user')
134+
->with(['showCompact' => true, '_disableLayout' => 1]),
121135
]
122-
]
123-
)->addHtml(new HtmlElement(
124-
'p',
125-
new Attributes(['class' => 'description']),
126-
new Text($this->translate(
127-
"Link existing Icinga Web users. Users from external authentication backends"
128-
. " won't be suggested and must be entered manually."
129-
))
130-
));
136+
)->addHtml(new HtmlElement(
137+
'p',
138+
new Attributes(['class' => 'description']),
139+
new Text($this->translate(
140+
"Link existing Icinga Web users. Users from external authentication backends"
141+
. " won't be suggested and must be entered manually."
142+
))
143+
));
131144

132145
$defaultChannel = $this->createElement(
133146
'select',
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
/* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */
4+
5+
namespace Icinga\Module\Notifications\Widget;
6+
7+
use Exception;
8+
use Icinga\Application\Config;
9+
use Icinga\Authentication\User\DomainAwareInterface;
10+
use Icinga\Authentication\User\UserBackend;
11+
use Icinga\Data\Selectable;
12+
use Icinga\Repository\Repository;
13+
use ipl\Html\Attributes;
14+
use ipl\Html\BaseHtmlElement;
15+
use ipl\Html\HtmlElement;
16+
use ipl\Html\Text;
17+
use ipl\I18n\Translation;
18+
use ipl\Web\Control\SearchBar\Suggestions;
19+
use Psr\Http\Message\ServerRequestInterface;
20+
21+
class IcingaWebUserSuggestions extends BaseHtmlElement
22+
{
23+
use Translation;
24+
25+
protected $tag = 'ul';
26+
27+
/** @var string */
28+
protected $searchTerm;
29+
30+
/** @var string */
31+
protected $originalValue;
32+
33+
public function setSearchTerm(string $term): self
34+
{
35+
$this->searchTerm = $term;
36+
37+
return $this;
38+
}
39+
40+
public function setOriginalValue(string $term): self
41+
{
42+
$this->originalValue = $term;
43+
44+
return $this;
45+
}
46+
47+
/**
48+
* Load suggestions as requested by the client
49+
*
50+
* @param ServerRequestInterface $request
51+
*
52+
* @return $this
53+
*/
54+
public function forRequest(ServerRequestInterface $request): self
55+
{
56+
if ($request->getMethod() !== 'POST') {
57+
return $this;
58+
}
59+
60+
$requestData = json_decode($request->getBody()->read(8192), true);
61+
if (empty($requestData)) {
62+
return $this;
63+
}
64+
65+
$this->setSearchTerm($requestData['term']['label']);
66+
$this->setOriginalValue($requestData['term']['search']);
67+
68+
return $this;
69+
}
70+
71+
protected function assemble(): void
72+
{
73+
$userBackends = [];
74+
foreach (Config::app('authentication') as $backendName => $backendConfig) {
75+
$candidate = UserBackend::create($backendName, $backendConfig);
76+
if ($candidate instanceof Selectable) {
77+
$userBackends[] = $candidate;
78+
}
79+
}
80+
81+
$limit = 10;
82+
while ($limit > 0 && ! empty($userBackends)) {
83+
/** @var Repository $backend */
84+
$backend = array_shift($userBackends);
85+
$query = $backend->select()
86+
->from('user', ['user_name'])
87+
->where('user_name', $this->searchTerm)
88+
->limit($limit);
89+
90+
try {
91+
$names = $query->fetchColumn();
92+
} catch (Exception $e) {
93+
continue;
94+
}
95+
96+
if (empty($names)) {
97+
continue;
98+
}
99+
100+
if ($backend instanceof DomainAwareInterface) {
101+
$names = array_map(function ($name) use ($backend) {
102+
return $name . '@' . $backend->getDomain();
103+
}, $names);
104+
}
105+
106+
$this->addHtml(
107+
new HtmlElement(
108+
'li',
109+
new Attributes(['class' => Suggestions::SUGGESTION_TITLE_CLASS]),
110+
new Text($this->translate('Backend')),
111+
new HtmlElement('span', new Attributes(['class' => 'badge']), new Text($backend->getName()))
112+
)
113+
);
114+
115+
foreach ($names as $name) {
116+
$this->addHtml(
117+
new HtmlElement(
118+
'li',
119+
null,
120+
new HtmlElement(
121+
'input',
122+
Attributes::create([
123+
'type' => 'button',
124+
'value' => $name,
125+
'data-label' => $name,
126+
'data-search' => $name,
127+
'data-class' => 'icinga-web-user',
128+
])
129+
)
130+
)
131+
);
132+
}
133+
134+
$limit -= count($names);
135+
}
136+
137+
if ($this->isEmpty()) {
138+
$this->addHtml(
139+
new HtmlElement(
140+
'li',
141+
Attributes::create(['class' => 'nothing-to-suggest']),
142+
new HtmlElement('em', null, Text::create($this->translate('Nothing to suggest')))
143+
)
144+
);
145+
}
146+
}
147+
148+
public function renderUnwrapped(): string
149+
{
150+
$this->ensureAssembled();
151+
152+
if ($this->isEmpty()) {
153+
return '';
154+
}
155+
156+
return parent::renderUnwrapped();
157+
}
158+
}

public/css/form.less

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,12 @@
177177
padding-bottom: 1em;
178178
border-bottom: 1px solid @gray-light;
179179
}
180+
181+
input.search {
182+
padding-left: 1.5em; // property was overwritten .icinga-controls
183+
}
184+
185+
.search-suggestions .badge {
186+
margin-left: 0.5em;
187+
}
180188
}

0 commit comments

Comments
 (0)