summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/web/src/FormElement/TermInput
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput.php450
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php144
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/Term.php89
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermContainer.php54
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php281
-rw-r--r--vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php38
6 files changed, 1056 insertions, 0 deletions
diff --git a/vendor/ipl/web/src/FormElement/TermInput.php b/vendor/ipl/web/src/FormElement/TermInput.php
new file mode 100644
index 0000000..352cce4
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput.php
@@ -0,0 +1,450 @@
+<?php
+
+namespace ipl\Web\FormElement;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\FieldsetElement;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Stdlib\Events;
+use ipl\Web\FormElement\TermInput\RegisteredTerm;
+use ipl\Web\FormElement\TermInput\TermContainer;
+use ipl\Web\FormElement\TermInput\ValidatedTerm;
+use ipl\Web\Url;
+use Psr\Http\Message\ServerRequestInterface;
+
+class TermInput extends FieldsetElement
+{
+ use Events;
+
+ /** @var string Emitted in case the user added new terms */
+ const ON_ADD = 'on_add';
+
+ /** @var string Emitted in case the user inserted new terms */
+ const ON_PASTE = 'on_paste';
+
+ /** @var string Emitted in case the user changed existing terms */
+ const ON_SAVE = 'on_save';
+
+ /** @var string Emitted in case the user removed terms */
+ const ON_REMOVE = 'on_remove';
+
+ /** @var string Emitted in case terms need to be enriched */
+ const ON_ENRICH = 'on_enrich';
+
+ /** @var Url The suggestion url */
+ protected $suggestionUrl;
+
+ /** @var bool Whether term direction is vertical */
+ protected $verticalTermDirection = false;
+
+ /** @var array Changes to transmit to the client */
+ protected $changes = [];
+
+ /** @var RegisteredTerm[] The terms */
+ protected $terms = [];
+
+ /** @var bool Whether this input has been automatically submitted */
+ private $hasBeenAutoSubmitted = false;
+
+ /** @var bool Whether the term input value has been pasted */
+ private $valueHasBeenPasted;
+
+ /** @var TermContainer The term container */
+ protected $termContainer;
+
+ /**
+ * Set the suggestion url
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setSuggestionUrl(Url $url): self
+ {
+ $this->suggestionUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Get the suggestion url
+ *
+ * @return ?Url
+ */
+ public function getSuggestionUrl(): ?Url
+ {
+ return $this->suggestionUrl;
+ }
+
+ /**
+ * Set whether term direction should be vertical
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setVerticalTermDirection(bool $state = true): self
+ {
+ $this->verticalTermDirection = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the desired term direction
+ *
+ * @return ?string
+ */
+ public function getTermDirection(): ?string
+ {
+ return $this->verticalTermDirection ? 'vertical' : null;
+ }
+
+ /**
+ * Set terms
+ *
+ * @param RegisteredTerm ...$terms
+ *
+ * @return $this
+ */
+ public function setTerms(RegisteredTerm ...$terms): self
+ {
+ $this->terms = $terms;
+
+ return $this;
+ }
+
+ /**
+ * Get the terms
+ *
+ * @return RegisteredTerm[]
+ */
+ public function getTerms(): array
+ {
+ return $this->terms;
+ }
+
+ public function getElements()
+ {
+ // TODO: Only a quick-fix. Remove once fieldsets are properly partially validated
+ $this->ensureAssembled();
+
+ return parent::getElements();
+ }
+
+ public function getValue($name = null, $default = null)
+ {
+ if ($name !== null) {
+ return parent::getValue($name, $default);
+ }
+
+ $terms = [];
+ foreach ($this->getTerms() as $term) {
+ $terms[] = $term->render(',');
+ }
+
+ return implode(',', $terms);
+ }
+
+ public function setValue($value)
+ {
+ $recipients = $value;
+ if (is_array($value)) {
+ $recipients = $value['value'] ?? '';
+ parent::setValue($value);
+ }
+
+ $terms = [];
+ foreach ($this->parseValue($recipients) as $term) {
+ $terms[] = new RegisteredTerm($term);
+ }
+
+ return $this->setTerms(...$terms);
+ }
+
+ /**
+ * Parse the given separated string of terms
+ *
+ * @param string $value
+ *
+ * @return string[]
+ */
+ public function parseValue(string $value): array
+ {
+ $terms = [];
+
+ $term = '';
+ $ignoreSeparator = false;
+ for ($i = 0; $i <= strlen($value); $i++) {
+ if (! isset($value[$i])) {
+ if (! empty($term)) {
+ $terms[] = rawurldecode($term);
+ }
+
+ break;
+ }
+
+ $c = $value[$i];
+ if ($c === '"') {
+ $ignoreSeparator = ! $ignoreSeparator;
+ } elseif (! $ignoreSeparator && $c === ',') {
+ $terms[] = rawurldecode($term);
+ $term = '';
+ } else {
+ $term .= $c;
+ }
+ }
+
+ return $terms;
+ }
+
+ /**
+ * Prepare updates to transmit for this input during multipart responses
+ *
+ * @param ServerRequestInterface $request
+ *
+ * @return array
+ */
+ public function prepareMultipartUpdate(ServerRequestInterface $request): array
+ {
+ $updates = [];
+ if ($this->valueHasBeenPasted()) {
+ $updates[] = $this->termContainer();
+ $updates[] = [
+ HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', []])),
+ 'Behavior:InputEnrichment'
+ ];
+ } elseif (! empty($this->changes)) {
+ $updates[] = [
+ HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', $this->changes])),
+ 'Behavior:InputEnrichment'
+ ];
+ }
+
+ if (empty($updates) && $this->hasBeenAutoSubmitted()) {
+ $updates[] = $updates[] = [
+ HtmlString::create(json_encode(['#' . $this->getName() . '-search-input', 'bogus'])),
+ 'Behavior:InputEnrichment'
+ ];
+ }
+
+ return $updates;
+ }
+
+ /**
+ * Get whether this input has been automatically submitted
+ *
+ * @return bool
+ */
+ private function hasBeenAutoSubmitted(): bool
+ {
+ return $this->hasBeenAutoSubmitted;
+ }
+
+ /**
+ * Get whether the term input value has been pasted
+ *
+ * @return bool
+ */
+ private function valueHasBeenPasted(): bool
+ {
+ if ($this->valueHasBeenPasted === null) {
+ $this->valueHasBeenPasted = ($this->getElement('data')->getValue()['type'] ?? null) === 'paste';
+ }
+
+ return $this->valueHasBeenPasted;
+ }
+
+ public function onRegistered(Form $form)
+ {
+ $termContainerId = $this->getName() . '-terms';
+ $mainInputId = $this->getName() . '-search-input';
+ $autoSubmittedBy = $form->getRequest()->getHeader('X-Icinga-Autosubmittedby');
+
+ $this->hasBeenAutoSubmitted = in_array($mainInputId, $autoSubmittedBy, true)
+ || in_array($termContainerId, $autoSubmittedBy, true);
+
+ parent::onRegistered($form);
+ }
+
+ /**
+ * Validate the given terms
+ *
+ * @param string $type The type of change to validate
+ * @param array $terms The terms affected by the change
+ * @param array $changes Potential changes made by validators
+ *
+ * @return bool
+ */
+ private function validateTerms(string $type, array $terms, array &$changes): bool
+ {
+ $validatedTerms = [];
+ foreach ($terms as $index => $data) {
+ $validatedTerms[$index] = ValidatedTerm::fromTermData($data);
+ }
+
+ switch ($type) {
+ case 'submit':
+ case 'exchange':
+ $type = self::ON_ADD;
+
+ break;
+ case 'paste':
+ $type = self::ON_PASTE;
+
+ break;
+ case 'save':
+ $type = self::ON_SAVE;
+
+ break;
+ case 'remove':
+ default:
+ return true;
+ }
+
+ $this->emit($type, [$validatedTerms]);
+
+ $invalid = false;
+ foreach ($validatedTerms as $index => $term) {
+ if (! $term->isValid()) {
+ $invalid = true;
+ }
+
+ if (! $term->isValid() || $term->hasBeenChanged()) {
+ $changes[$index] = $term->toTermData();
+ }
+ }
+
+ return $invalid;
+ }
+
+ /**
+ * Get the term container
+ *
+ * @return TermContainer
+ */
+ protected function termContainer(): TermContainer
+ {
+ if ($this->termContainer === null) {
+ $this->termContainer = (new TermContainer($this))
+ ->setAttribute('id', $this->getName() . '-terms');
+ }
+
+ return $this->termContainer;
+ }
+
+ protected function assemble()
+ {
+ $myName = $this->getName();
+
+ $termInputId = $myName . '-term-input';
+ $dataInputId = $myName . '-data-input';
+ $searchInputId = $myName . '-search-input';
+ $suggestionsId = $myName . '-suggestions';
+
+ $termContainer = $this->termContainer();
+
+ $suggestions = (new HtmlElement('div'))
+ ->setAttribute('id', $suggestionsId)
+ ->setAttribute('class', 'search-suggestions');
+
+ $termInput = $this->createElement('hidden', 'value', [
+ 'id' => $termInputId,
+ 'disabled' => true
+ ]);
+
+ $dataInput = new class ('data', [
+ 'ignore' => true,
+ 'id' => $dataInputId,
+ 'validators' => ['callback' => function ($data) use ($termContainer) {
+ $changes = [];
+ $invalid = $this->validateTerms($data['type'], $data['terms'] ?? [], $changes);
+ $this->changes = $changes;
+
+ $terms = $this->getTerms();
+ foreach ($changes as $index => $termData) {
+ $terms[$index]->applyTermData($termData);
+ }
+
+ return ! $invalid;
+ }]
+ ]) extends HiddenElement {
+ /** @var TermInput */
+ private $parent;
+
+ public function setParent(TermInput $parent): void
+ {
+ $this->parent = $parent;
+ }
+
+ public function setValue($value)
+ {
+ $data = json_decode($value, true);
+ if (($data['type'] ?? null) === 'paste') {
+ array_push($data['terms'], ...array_map(function ($t) {
+ return ['search' => $t];
+ }, $this->parent->parseValue($data['input'])));
+ }
+
+ return parent::setValue($data);
+ }
+
+ public function getValueAttribute()
+ {
+ return null;
+ }
+ };
+ $dataInput->setParent($this);
+
+ $label = $this->getLabel();
+ $this->setLabel(null);
+
+ // TODO: Separator customization
+ $mainInput = $this->createElement('text', 'value', [
+ 'id' => $searchInputId,
+ 'label' => $label,
+ 'required' => $this->isRequired(),
+ 'placeholder' => $this->translate('Type to search. Separate multiple terms by comma.'),
+ 'class' => 'term-input',
+ 'autocomplete' => 'off',
+ 'data-term-separator' => ',',
+ 'data-enrichment-type' => 'terms',
+ 'data-with-multi-completion' => true,
+ 'data-no-auto-submit-on-remove' => true,
+ 'data-term-direction' => $this->getTermDirection(),
+ 'data-data-input' => '#' . $dataInputId,
+ 'data-term-input' => '#' . $termInputId,
+ 'data-term-container' => '#' . $termContainer->getAttribute('id')->getValue(),
+ 'data-term-suggestions' => '#' . $suggestionsId
+ ]);
+ $mainInput->getAttributes()
+ ->registerAttributeCallback('value', function () {
+ return null;
+ });
+ if ($this->getSuggestionUrl() !== null) {
+ $mainInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
+ return (string) $this->getSuggestionUrl();
+ });
+ }
+
+ $this->addElement($termInput);
+ $this->addElement($dataInput);
+ $this->addElement($mainInput);
+
+ $mainInput->prependWrapper((new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['term-input-area', $this->getTermDirection()]]),
+ $termContainer,
+ new HtmlElement('label', null, $mainInput)
+ )));
+
+ $this->addHtml($suggestions);
+
+ if (! $this->hasBeenAutoSubmitted()) {
+ $this->emit(self::ON_ENRICH, [$this->getTerms()]);
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php
new file mode 100644
index 0000000..dd79dd1
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput/RegisteredTerm.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace ipl\Web\FormElement\TermInput;
+
+class RegisteredTerm implements Term
+{
+ /** @var string The search value */
+ protected $value;
+
+ /** @var ?string The label */
+ protected $label;
+
+ /** @var ?string The CSS class */
+ protected $class;
+
+ /** @var string The failure message */
+ protected $message;
+
+ /** @var string The validation constraint */
+ protected $pattern;
+
+ /**
+ * Create a new RegisteredTerm
+ *
+ * @param string $value The search value
+ */
+ public function __construct(string $value)
+ {
+ $this->setSearchValue($value);
+ }
+
+ public function setSearchValue(string $value): self
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ public function getSearchValue(): string
+ {
+ return $this->value;
+ }
+
+ public function setLabel(string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function setClass(string $class): self
+ {
+ $this->class = $class;
+
+ return $this;
+ }
+
+ public function getClass(): ?string
+ {
+ return $this->class;
+ }
+
+ public function setMessage(string $message): self
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ public function setPattern(string $pattern): self
+ {
+ $this->pattern = $pattern;
+
+ return $this;
+ }
+
+ public function getPattern(): ?string
+ {
+ if ($this->message === null) {
+ return null;
+ }
+
+ return $this->pattern ?? sprintf(Term::DEFAULT_CONSTRAINT, $this->getLabel() ?? $this->getSearchValue());
+ }
+
+ /**
+ * Render this term as a string
+ *
+ * Pass the separator being used to separate multiple terms. If the term's value contains it,
+ * the result will be automatically quoted.
+ *
+ * @param string $separator
+ *
+ * @return string
+ */
+ public function render(string $separator): string
+ {
+ if (strpos($this->value, $separator) !== false) {
+ return '"' . $this->value . '"';
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * Apply the given term data to this term
+ *
+ * @param array $termData
+ *
+ * @return void
+ */
+ public function applyTermData(array $termData): void
+ {
+ if (isset($termData['search'])) {
+ $this->value = $termData['search'];
+ }
+
+ if (isset($termData['label'])) {
+ $this->setLabel($termData['label']);
+ }
+
+ if (isset($termData['class'])) {
+ $this->setClass($termData['class']);
+ }
+
+ if (isset($termData['invalidMsg'])) {
+ $this->setMessage($termData['invalidMsg']);
+ }
+
+ if (isset($termData['pattern'])) {
+ $this->setPattern($termData['pattern']);
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/FormElement/TermInput/Term.php b/vendor/ipl/web/src/FormElement/TermInput/Term.php
new file mode 100644
index 0000000..be08e8a
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput/Term.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace ipl\Web\FormElement\TermInput;
+
+interface Term
+{
+ /** @var string The default validation constraint */
+ public const DEFAULT_CONSTRAINT = '^\s*(?!%s\b).*\s*$';
+
+ /**
+ * Set the search value
+ *
+ * @param string $value
+ *
+ * @return $this
+ */
+ public function setSearchValue(string $value);
+
+ /**
+ * Get the search value
+ *
+ * @return string
+ */
+ public function getSearchValue(): string;
+
+ /**
+ * Set the label
+ *
+ * @param string $label
+ *
+ * @return $this
+ */
+ public function setLabel(string $label);
+
+ /**
+ * Get the label
+ *
+ * @return ?string
+ */
+ public function getLabel(): ?string;
+
+ /**
+ * Set the CSS class
+ *
+ * @param string $class
+ *
+ * @return $this
+ */
+ public function setClass(string $class);
+
+ /**
+ * Get the CSS class
+ *
+ * @return ?string
+ */
+ public function getClass(): ?string;
+
+ /**
+ * Set the failure message
+ *
+ * @param string $message
+ *
+ * @return $this
+ */
+ public function setMessage(string $message);
+
+ /**
+ * Get the failure message
+ *
+ * @return ?string
+ */
+ public function getMessage(): ?string;
+
+ /**
+ * Set the validation constraint
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ public function setPattern(string $pattern);
+
+ /**
+ * Get the validation constraint
+ *
+ * @return ?string
+ */
+ public function getPattern(): ?string;
+}
diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php
new file mode 100644
index 0000000..c5a614c
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput/TermContainer.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace ipl\Web\FormElement\TermInput;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\FormElement\TermInput;
+
+class TermContainer extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'terms'];
+
+ /** @var TermInput */
+ protected $input;
+
+ /**
+ * Create a new TermContainer
+ *
+ * @param TermInput $input
+ */
+ public function __construct(TermInput $input)
+ {
+ $this->input = $input;
+ }
+
+ protected function assemble()
+ {
+ foreach ($this->input->getTerms() as $i => $term) {
+ $label = $term->getLabel() ?: $term->getSearchValue();
+
+ $this->addHtml(new HtmlElement(
+ 'label',
+ Attributes::create([
+ 'class' => $term->getClass(),
+ 'data-search' => $term->getSearchValue(),
+ 'data-label' => $label,
+ 'data-index' => $i
+ ]),
+ new HtmlElement(
+ 'input',
+ Attributes::create([
+ 'type' => 'text',
+ 'value' => $label,
+ 'pattern' => $term->getPattern(),
+ 'data-invalid-msg' => $term->getMessage()
+ ])
+ )
+ ));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php
new file mode 100644
index 0000000..26b00ea
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput/TermSuggestions.php
@@ -0,0 +1,281 @@
+<?php
+
+namespace ipl\Web\FormElement\TermInput;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use Psr\Http\Message\ServerRequestInterface;
+use Traversable;
+
+use function ipl\Stdlib\yield_groups;
+
+class TermSuggestions extends BaseHtmlElement
+{
+ use Translation;
+
+ protected $tag = 'ul';
+
+ /** @var Traversable */
+ protected $provider;
+
+ /** @var ?callable */
+ protected $groupingCallback;
+
+ /** @var ?string */
+ protected $searchTerm;
+
+ /** @var ?string */
+ protected $searchPattern;
+
+ /** @var ?string */
+ protected $originalValue;
+
+ /** @var string[] */
+ protected $excludeTerms = [];
+
+ /**
+ * Create new TermSuggestions
+ *
+ * The provider must deliver terms in form of arrays with the following keys:
+ * * (required) search: The search value
+ * * label: A human-readable label
+ * * class: A CSS class
+ * * title: A message shown upon hover on the term
+ *
+ * Any excess key is also transferred to the client, but currently unused.
+ *
+ * @param Traversable $provider
+ */
+ public function __construct(Traversable $provider)
+ {
+ $this->provider = $provider;
+ }
+
+ /**
+ * Set a callback to identify groups for terms delivered by the provider
+ *
+ * The callback must return a string which is used as label for the group.
+ * Its interface is: `function (array $data): string`
+ *
+ * @param callable $callback
+ *
+ * @return $this
+ */
+ public function setGroupingCallback(callable $callback): self
+ {
+ $this->groupingCallback = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Get the callback used to identify groups for terms delivered by the provider
+ *
+ * @return ?callable
+ */
+ public function getGroupingCallback(): ?callable
+ {
+ return $this->groupingCallback;
+ }
+
+ /**
+ * Set the search term (can contain `*` wildcards)
+ *
+ * @param string $term
+ *
+ * @return $this
+ */
+ public function setSearchTerm(string $term): self
+ {
+ $this->searchTerm = $term;
+ $this->setSearchPattern(
+ '/' . str_replace(
+ '\\000',
+ '.*',
+ preg_quote(
+ str_replace(
+ '*',
+ "\0",
+ $term
+ ),
+ '/'
+ )
+ ) . '/i'
+ );
+
+ return $this;
+ }
+
+ /**
+ * Get the search term
+ *
+ * @return ?string
+ */
+ public function getSearchTerm(): ?string
+ {
+ return $this->searchTerm;
+ }
+
+ /**
+ * Set the search pattern used by {@see matchSearch}
+ *
+ * @param string $pattern
+ *
+ * @return $this
+ */
+ protected function setSearchPattern(string $pattern): self
+ {
+ $this->searchPattern = $pattern;
+
+ return $this;
+ }
+
+ /**
+ * Set the original search value
+ *
+ * The one without automatically added wildcards.
+ *
+ * @param string $term
+ *
+ * @return $this
+ */
+ public function setOriginalSearchValue(string $term): self
+ {
+ $this->originalValue = $term;
+
+ return $this;
+ }
+
+ /**
+ * Get the original search value
+ *
+ * @return ?string
+ */
+ public function getOriginalSearchValue(): ?string
+ {
+ return $this->originalValue;
+ }
+
+ /**
+ * Set the terms to exclude in the suggestion list
+ *
+ * @param string[] $terms
+ *
+ * @return $this
+ */
+ public function setExcludeTerms(array $terms): self
+ {
+ $this->excludeTerms = $terms;
+
+ return $this;
+ }
+
+ /**
+ * Get the terms to exclude in the suggestion list
+ *
+ * @return string[]
+ */
+ public function getExcludeTerms(): array
+ {
+ return $this->excludeTerms;
+ }
+
+ /**
+ * Match the given search term against the users search
+ *
+ * @param string $term
+ *
+ * @return bool Whether the search matches or not
+ */
+ public function matchSearch(string $term): bool
+ {
+ if (! $this->searchPattern || $this->searchPattern === '.*') {
+ return true;
+ }
+
+ return (bool) preg_match($this->searchPattern, $term);
+ }
+
+ /**
+ * Load suggestions as requested by the client
+ *
+ * @param ServerRequestInterface $request
+ *
+ * @return $this
+ */
+ public function forRequest(ServerRequestInterface $request): self
+ {
+ if ($request->getMethod() !== 'POST') {
+ return $this;
+ }
+
+ /** @var array<string, array<int|string, string>> $requestData */
+ $requestData = json_decode($request->getBody()->read(8192), true);
+ if (empty($requestData)) {
+ return $this;
+ }
+
+ $this->setSearchTerm($requestData['term']['label']);
+ $this->setOriginalSearchValue($requestData['term']['search']);
+ $this->setExcludeTerms($requestData['exclude'] ?? []);
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $groupingCallback = $this->getGroupingCallback();
+ if ($groupingCallback) {
+ $provider = yield_groups($this->provider, $groupingCallback);
+ } else {
+ $provider = [null => $this->provider];
+ }
+
+ /** @var iterable<?string, array<array<string, string>>> $provider */
+ foreach ($provider as $group => $suggestions) {
+ if ($group) {
+ $this->addHtml(
+ new HtmlElement(
+ 'li',
+ Attributes::create([
+ 'class' => 'suggestion-title'
+ ]),
+ Text::create($group)
+ )
+ );
+ }
+
+ foreach ($suggestions as $data) {
+ $attributes = [
+ 'type' => 'button',
+ 'value' => $data['label'] ?? $data['search']
+ ];
+ foreach ($data as $name => $value) {
+ $attributes["data-$name"] = $value;
+ }
+
+ $this->addHtml(
+ new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement(
+ 'input',
+ Attributes::create($attributes)
+ )
+ )
+ );
+ }
+ }
+
+ 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')))
+ ));
+ }
+ }
+}
diff --git a/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php
new file mode 100644
index 0000000..e91c203
--- /dev/null
+++ b/vendor/ipl/web/src/FormElement/TermInput/ValidatedTerm.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace ipl\Web\FormElement\TermInput;
+
+use BadMethodCallException;
+
+class ValidatedTerm extends \ipl\Web\Control\SearchBar\ValidatedTerm implements Term
+{
+ const DEFAULT_PATTERN = Term::DEFAULT_CONSTRAINT;
+
+ /** @var ?string The CSS class */
+ protected $class;
+
+ public function setClass(string $class): Term
+ {
+ $this->class = $class;
+
+ return $this;
+ }
+
+ public function getClass(): ?string
+ {
+ return $this->class;
+ }
+
+ public function toTermData()
+ {
+ $data = parent::toTermData();
+ $data['class'] = $this->getClass();
+
+ return $data;
+ }
+
+ public function toMetaData()
+ {
+ throw new BadMethodCallException(self::class . '::toTermData() not implemented yet');
+ }
+}