diff options
Diffstat (limited to '')
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'); + } +} |