diff options
Diffstat (limited to 'vendor/ipl/web')
51 files changed, 6204 insertions, 0 deletions
diff --git a/vendor/ipl/web/LICENSE b/vendor/ipl/web/LICENSE new file mode 100644 index 0000000..a904102 --- /dev/null +++ b/vendor/ipl/web/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2018 Icinga GmbH https://icinga.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/ipl/web/composer.json b/vendor/ipl/web/composer.json new file mode 100644 index 0000000..90173f1 --- /dev/null +++ b/vendor/ipl/web/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ipl/web", + "type": "library", + "description": "Icinga PHP Library - Web Components", + "keywords": ["html"], + "homepage": "https://github.com/Icinga/ipl-web", + "license": "MIT", + "autoload": { + "psr-4": { + "ipl\\Web\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "ipl\\Tests\\Web\\": "tests" + } + }, + "require": { + "php": ">=7.2", + "ext-json": "*", + "ipl/html": ">=0.6.0", + "ipl/i18n": ">=0.2.0", + "ipl/stdlib": ">=0.12.0", + "fortawesome/font-awesome": "^6" + } +} diff --git a/vendor/ipl/web/src/Common/BaseTarget.php b/vendor/ipl/web/src/Common/BaseTarget.php new file mode 100644 index 0000000..db9be28 --- /dev/null +++ b/vendor/ipl/web/src/Common/BaseTarget.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Common; + +/** + * @method \ipl\Html\Attributes getAttributes() + */ +trait BaseTarget +{ + /** + * Get the data-base-target attribute + * + * @return string|null + */ + public function getBaseTarget() + { + return $this->getAttributes()->get('data-base-target')->getValue(); + } + + /** + * Set the data-base-target attribute + * + * @param string $target + * + * @return $this + */ + public function setBaseTarget($target) + { + $this->getAttributes()->set('data-base-target', $target); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Common/Card.php b/vendor/ipl/web/src/Common/Card.php new file mode 100644 index 0000000..e62bab5 --- /dev/null +++ b/vendor/ipl/web/src/Common/Card.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +abstract class Card extends BaseHtmlElement +{ + protected $tag = 'section'; + + abstract protected function assembleBody(BaseHtmlElement $body); + + abstract protected function assembleFooter(BaseHtmlElement $footer); + + abstract protected function assembleHeader(BaseHtmlElement $header); + + protected function createBody() + { + $body = Html::tag('div', ['class' => 'card-body']); + + $this->assembleBody($body); + + return $body; + } + + protected function createFooter() + { + $footer = Html::tag('div', ['class' => 'card-footer']); + + $this->assembleFooter($footer); + + return $footer; + } + + protected function createHeader() + { + $header = Html::tag('div', ['class' => 'card-header']); + + $this->assembleHeader($header); + + return $header; + } + + protected function assemble() + { + $this->addAttributes(['class' => 'card']); + + $this->add([ + $this->createHeader(), + $this->createBody(), + $this->createFooter() + ]); + } +} diff --git a/vendor/ipl/web/src/Common/CsrfCounterMeasure.php b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php new file mode 100644 index 0000000..348c4ee --- /dev/null +++ b/vendor/ipl/web/src/Common/CsrfCounterMeasure.php @@ -0,0 +1,48 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; + +trait CsrfCounterMeasure +{ + /** + * Create a form element to counter measure CSRF attacks + * + * @param string $uniqueId A unique ID that persists through different requests + * + * @return FormElement + */ + protected function createCsrfCounterMeasure($uniqueId) + { + $hashAlgo = in_array('sha3-256', hash_algos(), true) ? 'sha3-256' : 'sha256'; + + $seed = random_bytes(16); + $token = base64_encode($seed) . '|' . hash($hashAlgo, $uniqueId . $seed); + + /** @var Form $this */ + return $this->createElement( + 'hidden', + 'CSRFToken', + [ + 'ignore' => true, + 'required' => true, + 'value' => $token, + 'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) { + if (strpos($token, '|') === false) { + die('Invalid CSRF token provided'); + } + + list($seed, $hash) = explode('|', $token); + + if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) { + die('Invalid CSRF token provided'); + } + + return true; + }] + ] + ); + } +} diff --git a/vendor/ipl/web/src/Common/FormUid.php b/vendor/ipl/web/src/Common/FormUid.php new file mode 100644 index 0000000..8453dd2 --- /dev/null +++ b/vendor/ipl/web/src/Common/FormUid.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Form; +use ipl\Html\Contract\FormElement; +use LogicException; + +trait FormUid +{ + protected $uidElementName = 'uid'; + + /** + * Create a form element to make this form distinguishable from others + * + * You'll have to define a name for the form for this to work. + * + * @return FormElement + */ + protected function createUidElement() + { + /** @var Form $this */ + $element = $this->createElement('hidden', $this->uidElementName); + $element->getAttributes()->registerAttributeCallback('value', function () { + /** @var Form $this */ + return $this->getAttributes()->get('name')->getValue(); + }); + + return $element; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method + * and the sent UID is the form's UID. + * + * @return bool + */ + public function hasBeenSent() + { + if (! parent::hasBeenSent()) { + return false; + } elseif ($this->getMethod() === 'GET') { + // Get forms are unlikely to require a UID. If they do, change this. + return true; + } + + /** @var Form $this */ + $name = $this->getAttributes()->get('name')->getValue(); + if (! $name) { + throw new LogicException('Form has no name'); + } + + $values = $this->getRequest()->getParsedBody(); + + return isset($values[$this->uidElementName]) && $values[$this->uidElementName] === $name; + } +} diff --git a/vendor/ipl/web/src/Common/RedirectOption.php b/vendor/ipl/web/src/Common/RedirectOption.php new file mode 100644 index 0000000..0d73ef8 --- /dev/null +++ b/vendor/ipl/web/src/Common/RedirectOption.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Common; + +use ipl\Html\Contract\FormElement; +use ipl\Html\Form; +use LogicException; + +trait RedirectOption +{ + /** + * Create a form element to retrieve the redirect target upon form submit + * + * @return FormElement + */ + protected function createRedirectOption() + { + /** @var Form $this */ + return $this->createElement('hidden', 'redirect'); + } + + /** + * @see Form::getRedirectUrl() + */ + public function getRedirectUrl() + { + /** @var Form $this */ + $redirectOption = $this->getValue('redirect'); + if (! $redirectOption) { + return parent::getRedirectUrl(); + } + + if (! $this->hasElement('CSRFToken') || ! $this->getElement('CSRFToken')->isValid()) { + throw new LogicException( + 'It is not safe to accept redirect targets from submit values without CSRF protection' + ); + } + + return $redirectOption; + } +} diff --git a/vendor/ipl/web/src/Common/StateBadges.php b/vendor/ipl/web/src/Common/StateBadges.php new file mode 100644 index 0000000..5898492 --- /dev/null +++ b/vendor/ipl/web/src/Common/StateBadges.php @@ -0,0 +1,184 @@ +<?php + +namespace ipl\Web\Common; + +use Icinga\Data\Filter\Filter; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\BaseFilter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Link; +use ipl\Web\Widget\StateBadge; + +abstract class StateBadges extends BaseHtmlElement +{ + use BaseFilter; + + /** @var object $item */ + protected $item; + + /** @var string */ + protected $type; + + /** @var string Prefix */ + protected $prefix; + + /** @var Url Badge link */ + protected $url; + + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'state-badges']; + + /** + * Create a new widget for state badges + * + * @param object $item + */ + public function __construct($item) + { + $this->item = $item; + $this->type = $this->getType(); + $this->prefix = $this->getPrefix(); + $this->url = $this->getBaseUrl(); + } + + /** + * Get the badge base URL + * + * @return Url + */ + abstract protected function getBaseUrl(): Url; + + /** + * Get the type of the items + * + * @return string + */ + abstract protected function getType(): string; + + /** + * Get the prefix for accessing state information + * + * @return string + */ + abstract protected function getPrefix(): string; + + /** + * Get the integer of the given state text + * + * @param string $state + * + * @return int + */ + abstract protected function getStateInt(string $state): int; + + /** + * Get the badge URL + * + * @return Url + */ + public function getUrl(): Url + { + return $this->url; + } + + /** + * Set the badge URL + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url): self + { + $this->url = $url; + + return $this; + } + + /** + * Create a badge link + * + * @param $content + * @param array $params + * + * @return Link + */ + public function createLink($content, array $params = null): Link + { + $url = clone $this->getUrl(); + + if (! empty($params)) { + $url->getParams()->mergeValues($params); + } + + if ($this->hasBaseFilter()) { + $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter()))); + } + + return new Link($content, $url); + } + + /** + * Create a state bade + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createBadge(string $state) + { + $key = $this->prefix . "_{$state}"; + + if (isset($this->item->$key) && $this->item->$key) { + return Html::tag('li', $this->createLink( + new StateBadge($this->item->$key, $state), + [$this->type . '.state.soft_state' => $this->getStateInt($state)] + )); + } + + return null; + } + + /** + * Create a state group + * + * @param string $state + * + * @return ?BaseHtmlElement + */ + protected function createGroup(string $state) + { + $content = []; + $handledKey = $this->prefix . "_{$state}_handled"; + $unhandledKey = $this->prefix . "_{$state}_unhandled"; + + if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$unhandledKey, $state), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'n' + ] + )); + } + + if (isset($this->item->$handledKey) && $this->item->$handledKey) { + $content[] = Html::tag('li', $this->createLink( + new StateBadge($this->item->$handledKey, $state, true), + [ + $this->type . '.state.soft_state' => $this->getStateInt($state), + $this->type . '.state.is_handled' => 'y' + ] + )); + } + + if (empty($content)) { + return null; + } + + return Html::tag('li', Html::tag('ul', $content)); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatController.php b/vendor/ipl/web/src/Compat/CompatController.php new file mode 100644 index 0000000..08e69f1 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatController.php @@ -0,0 +1,329 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use InvalidArgumentException; +use Icinga\Web\Controller; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; +use ipl\Html\ValidHtml; +use ipl\Web\Control\PaginationControl; +use ipl\Web\Control\SearchBar; +use ipl\Web\Layout\Content; +use ipl\Web\Layout\Controls; +use ipl\Web\Layout\Footer; +use ipl\Web\Url; +use ipl\Web\Widget\Tabs; +use LogicException; +use Psr\Http\Message\ServerRequestInterface; + +class CompatController extends Controller +{ + /** @var Content */ + protected $content; + + /** @var Controls */ + protected $controls; + + /** @var HtmlDocument */ + protected $document; + + /** @var Footer */ + protected $footer; + + /** @var Tabs */ + protected $tabs; + + /** @var array */ + protected $parts; + + protected function prepareInit() + { + parent::prepareInit(); + + $this->params->shift('isIframe'); + $this->params->shift('showFullscreen'); + $this->params->shift('showCompact'); + $this->params->shift('renderLayout'); + $this->params->shift('_disableLayout'); + $this->params->shift('_dev'); + if ($this->params->get('view') === 'compact') { + $this->params->remove('view'); + } + + $this->document = new HtmlDocument(); + $this->document->setSeparator("\n"); + $this->controls = new Controls(); + $this->controls->setAttribute('id', $this->getRequest()->protectId('controls')); + $this->content = new Content(); + $this->content->setAttribute('id', $this->getRequest()->protectId('content')); + $this->footer = new Footer(); + $this->footer->setAttribute('id', $this->getRequest()->protectId('footer')); + $this->tabs = new Tabs(); + $this->tabs->setAttribute('id', $this->getRequest()->protectId('tabs')); + $this->parts = []; + + $this->view->tabs = $this->tabs; + $this->controls->setTabs($this->tabs); + + ViewRenderer::inject(); + + $this->view->document = $this->document; + } + + /** + * Get the current server request + * + * @return ServerRequestInterface + */ + public function getServerRequest() + { + return ServerRequest::fromGlobals(); + } + + /** + * Get the document + * + * @return HtmlDocument + */ + public function getDocument() + { + return $this->document; + } + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Add content + * + * @param ValidHtml $content + * + * @return $this + */ + protected function addContent(ValidHtml $content) + { + $this->content->add($content); + + return $this; + } + + /** + * Add a control + * + * @param ValidHtml $control + * + * @return $this + */ + protected function addControl(ValidHtml $control) + { + $this->controls->add($control); + + return $this; + } + + /** + * Add footer + * + * @param ValidHtml $footer + * + * @return $this + */ + protected function addFooter(ValidHtml $footer) + { + $this->footer->add($footer); + + return $this; + } + + /** + * Add a part to be served as multipart-content + * + * If an id is passed the element is used as-is as the part's content. + * Otherwise (no id given) the element's content is used instead. + * + * @param ValidHtml $element + * @param string $id If not given, this is taken from $element + * + * @throws InvalidArgumentException If no id is given and the element also does not have one + * + * @return $this + */ + protected function addPart(ValidHtml $element, $id = null) + { + $part = new Multipart(); + + if ($id === null) { + if (! $element instanceof BaseHtmlElement) { + throw new InvalidArgumentException('If no id is given, $element must be a BaseHtmlElement'); + } + + $id = $element->getAttributes()->get('id')->getValue(); + if (! $id) { + throw new InvalidArgumentException('Element has no id'); + } + + $part->addFrom($element); + } else { + $part->add($element); + } + + $this->parts[] = $part->setFor($id); + + return $this; + } + + /** + * Set the given title as the window's title + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function setTitle($title, ...$args) + { + if (! empty($args)) { + $title = vsprintf($title, $args); + } + + $this->view->title = $title; + + return $this; + } + + /** + * Add an active tab with the given title and set it as the window's title too + * + * @param string $title + * @param mixed ...$args + * + * @return $this + */ + protected function addTitleTab($title, ...$args) + { + $this->setTitle($title, ...$args); + + $tabName = uniqid(); + $this->getTabs()->add($tabName, [ + 'label' => $this->view->title, + 'url' => $this->getRequest()->getUrl() + ])->activate($tabName); + + return $this; + } + + /** + * Send a multipart update instead of a standard response + * + * As part of a multipart update, the tabs, content and footer as well as selected controls are + * transmitted in a way the client can render them exclusively instead of a full column reload. + * + * By default the only control included in the response is the pagination control, if added. + * + * @param BaseHtmlElement ...$additionalControls Additional controls to include + * + * @throws LogicException In case an additional control has not been added + */ + public function sendMultipartUpdate(BaseHtmlElement ...$additionalControls) + { + $searchBar = null; + $pagination = null; + $redirectUrl = null; + foreach ($this->controls->getContent() as $control) { + if ($control instanceof PaginationControl) { + $pagination = $control; + } elseif ($control instanceof SearchBar) { + $searchBar = $control; + $redirectUrl = $control->getRedirectUrl(); /** @var Url $redirectUrl */ + } + } + + if ($searchBar !== null && ($changes = $searchBar->getChanges()) !== null) { + $this->addPart(HtmlString::create(json_encode($changes)), 'Behavior:InputEnrichment'); + } + + foreach ($additionalControls as $control) { + $this->addPart($control); + } + + if ($searchBar !== null && $this->content->isEmpty() && ! $searchBar->isValid()) { + // No content and an invalid search bar? That's it then, further updates are not required + return; + } + + if ($this->tabs->count() > 0) { + if ($redirectUrl !== null) { + $this->tabs->setRefreshUrl($redirectUrl); + $this->tabs->getActiveTab()->setUrl($redirectUrl); + + // As long as we still depend on the legacy tab implementation + // there is no other way to influence what the tab extensions + // use as url. (https://github.com/Icinga/icingadb-web/issues/373) + $oldPathInfo = $this->getRequest()->getPathInfo(); + $oldQuery = $_SERVER['QUERY_STRING']; + $this->getRequest()->setPathInfo('/' . $redirectUrl->getPath()); + $_SERVER['QUERY_STRING'] = $redirectUrl->getParams()->toString(); + $this->tabs->ensureAssembled(); + $this->getRequest()->setPathInfo($oldPathInfo); + $_SERVER['QUERY_STRING'] = $oldQuery; + } + + $this->addPart($this->tabs); + } + + if ($pagination !== null) { + if ($redirectUrl !== null) { + $pagination->setUrl(clone $redirectUrl); + } + + $this->addPart($pagination); + } + + if (! $this->content->isEmpty()) { + $this->addPart($this->content); + } + + if (! $this->footer->isEmpty()) { + $this->addPart($this->footer); + } + + if ($redirectUrl !== null) { + $this->getResponse()->setHeader('X-Icinga-Location-Query', $redirectUrl->getParams()->toString()); + } + } + + public function postDispatch() + { + if (empty($this->parts)) { + if (! $this->content->isEmpty()) { + $this->document->prepend($this->content); + + if (! $this->view->compact && ! $this->controls->isEmpty()) { + $this->document->prepend($this->controls); + } + + if (! $this->footer->isEmpty()) { + $this->document->add($this->footer); + } + } + } else { + $partSeparator = base64_encode(random_bytes(16)); + $this->getResponse()->setHeader('X-Icinga-Multipart-Content', $partSeparator); + + $this->document->setSeparator("\n$partSeparator\n"); + $this->document->add($this->parts); + } + + parent::postDispatch(); + } +} diff --git a/vendor/ipl/web/src/Compat/CompatDecorator.php b/vendor/ipl/web/src/Compat/CompatDecorator.php new file mode 100644 index 0000000..856b758 --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatDecorator.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Web\FormDecorator\IcingaFormDecorator; + +/** + * Compat form element decorator based on div elements + * + * @deprecated Use {@see \ipl\Web\FormDecorator\IcingaFormDecorator} instead + */ +class CompatDecorator extends IcingaFormDecorator +{ +} diff --git a/vendor/ipl/web/src/Compat/CompatForm.php b/vendor/ipl/web/src/Compat/CompatForm.php new file mode 100644 index 0000000..b759b8f --- /dev/null +++ b/vendor/ipl/web/src/Compat/CompatForm.php @@ -0,0 +1,25 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Html\Form; +use ipl\I18n\Translation; +use ipl\Web\FormDecorator\IcingaFormDecorator; + +class CompatForm extends Form +{ + use Translation; + + protected $defaultAttributes = ['class' => 'icinga-form icinga-controls']; + + public function hasDefaultElementDecorator() + { + if (parent::hasDefaultElementDecorator()) { + return true; + } + + $this->setDefaultElementDecorator(new IcingaFormDecorator()); + + return true; + } +} diff --git a/vendor/ipl/web/src/Compat/Multipart.php b/vendor/ipl/web/src/Compat/Multipart.php new file mode 100644 index 0000000..432f837 --- /dev/null +++ b/vendor/ipl/web/src/Compat/Multipart.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Compat; + +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlString; + +class Multipart extends HtmlDocument +{ + /** @var string */ + protected $for; + + protected $contentSeparator = "\n"; + + /** + * Set the container's id which this part is for + * + * @param string $id + * + * @return $this + */ + public function setFor($id) + { + $this->for = $id; + + return $this; + } + + protected function assemble() + { + $this->prepend(HtmlString::create(sprintf('for=%s', $this->for))); + } +} diff --git a/vendor/ipl/web/src/Compat/SearchControls.php b/vendor/ipl/web/src/Compat/SearchControls.php new file mode 100644 index 0000000..c40204d --- /dev/null +++ b/vendor/ipl/web/src/Compat/SearchControls.php @@ -0,0 +1,269 @@ +<?php + +namespace ipl\Web\Compat; + +use GuzzleHttp\Psr7\ServerRequest; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Query; +use ipl\Stdlib\Seq; +use ipl\Web\Control\SearchBar; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Stdlib\Filter; + +trait SearchControls +{ + /** + * Fetch available filter columns for the given query + * + * @param Query $query + * + * @return array<string, string> Keys are column paths, values are labels + */ + abstract public function fetchFilterColumns(Query $query); + + /** + * Get whether {@see SearchControls::createSearchBar()} and {@see SearchControls::createSearchEditor()} + * should handle form submits. + * + * @return bool + */ + private function callHandleRequest() + { + return true; + } + + /** + * Create and return the SearchBar + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchBar + */ + public function createSearchBar(Query $query, ...$params): SearchBar + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = $requestUrl->onlyWith($preserveParams); + } + + $filter = QueryString::fromString((string) $this->params) + ->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + $this->enrichFilterCondition($condition, $query); + }) + ->parse(); + + $searchBar = new SearchBar(); + $searchBar->setFilter($filter); + $searchBar->setRedirectUrl($redirectUrl); + $searchBar->setAction($redirectUrl->getAbsoluteUrl()); + $searchBar->setIdProtector([$this->getRequest(), 'protectId']); + + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if (method_exists($this, 'completeAction')) { + $searchBar->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + if (method_exists($this, 'searchEditorAction')) { + $searchBar->setEditorUrl(Url::fromPath( + "$moduleName/$controllerName/search-editor" + )->setParams($redirectUrl->getParams())); + } + + $filterColumns = $this->fetchFilterColumns($query); + $columnValidator = function (SearchBar\ValidatedColumn $column) use ($query, $filterColumns) { + $searchPath = $column->getSearchValue(); + if (strpos($searchPath, '.') === false) { + $column->setSearchValue($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableName() + )); + } + + try { + $definition = $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + list($columnPath, $columnLabel) = Seq::find($filterColumns, $searchPath, false); + if ($columnPath === null) { + $column->setMessage(t('Is not a valid column')); + $column->setSearchValue($searchPath); // Resets the qualification made above + } else { + $column->setSearchValue($columnPath); + $column->setLabel($columnLabel); + } + } + + if (isset($definition)) { + $column->setLabel($definition->getLabel()); + } + }; + + $searchBar->on(SearchBar::ON_ADD, $columnValidator) + ->on(SearchBar::ON_INSERT, $columnValidator) + ->on(SearchBar::ON_SAVE, $columnValidator) + ->on(SearchBar::ON_SENT, function (SearchBar $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $existingParams = $redirectUrl->getParams(); + $redirectUrl->setQueryString(QueryString::render($form->getFilter())); + foreach ($existingParams->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } + + $redirectUrl->getParams()->addEncoded($name, $value); + } + + $form->setRedirectUrl($redirectUrl); + })->on(SearchBar::ON_SUCCESS, function (SearchBar $form) { + $this->getResponse()->redirectAndExit($form->getRedirectUrl()); + }); + + if ($this->callHandleRequest()) { + $searchBar->handleRequest(ServerRequest::fromGlobals()); + } + + return $searchBar; + } + + /** + * Create and return the SearchEditor + * + * @param Query $query The query being filtered + * @param Url $redirectUrl Url to redirect to upon success + * @param array $preserveParams Query params to preserve when redirecting + * + * @return SearchEditor + */ + public function createSearchEditor(Query $query, ...$params): SearchEditor + { + $requestUrl = Url::fromRequest(); + $preserveParams = array_pop($params) ?? []; + $redirectUrl = array_pop($params); + $moduleName = $this->getRequest()->getModuleName(); + $controllerName = $this->getRequest()->getControllerName(); + + if ($redirectUrl !== null) { + $redirectUrl->addParams($requestUrl->onlyWith($preserveParams)->getParams()->toArray(false)); + } else { + $redirectUrl = Url::fromPath("$moduleName/$controllerName"); + if (! empty($preserveParams)) { + $redirectUrl->setParams($requestUrl->onlyWith($preserveParams)->getParams()); + } + } + + $editor = new SearchEditor(); + $editor->setRedirectUrl($redirectUrl); + $editor->setAction($requestUrl->getAbsoluteUrl()); + $editor->setQueryString((string) $this->params->without($preserveParams)); + + if (method_exists($this, 'completeAction')) { + $editor->setSuggestionUrl(Url::fromPath( + "$moduleName/$controllerName/complete", + ['_disableLayout' => true, 'showCompact' => true] + )); + } + + $editor->getParser()->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ($query) { + if ($condition->getColumn()) { + $this->enrichFilterCondition($condition, $query); + } + }); + + $filterColumns = $this->fetchFilterColumns($query); + $editor->on(SearchEditor::ON_VALIDATE_COLUMN, function ( + Filter\Condition $condition + ) use ( + $query, + $filterColumns + ) { + $searchPath = $condition->getColumn(); + if (strpos($searchPath, '.') === false) { + $condition->setColumn($query->getResolver()->qualifyPath( + $searchPath, + $query->getModel()->getTableName() + )); + } + + try { + $query->getResolver()->getColumnDefinition($searchPath); + } catch (InvalidRelationException $_) { + $columnPath = Seq::findKey( + $filterColumns, + $condition->metaData()->get('columnLabel', $searchPath), + false + ); + if ($columnPath === null) { + $condition->setColumn($searchPath); + throw new SearchBar\SearchException(t('Is not a valid column')); + } else { + $condition->setColumn($columnPath); + } + } + })->on(SearchEditor::ON_SUCCESS, function (SearchEditor $form) { + /** @var Url $redirectUrl */ + $redirectUrl = $form->getRedirectUrl(); + $existingParams = $redirectUrl->getParams(); + $redirectUrl->setQueryString(QueryString::render($form->getFilter())); + foreach ($existingParams->toArray(false) as $name => $value) { + if (is_int($name)) { + $name = $value; + $value = true; + } + + $redirectUrl->getParams()->addEncoded($name, $value); + } + + $this->getResponse() + ->setHeader('X-Icinga-Container', '_self') + ->redirectAndExit($redirectUrl); + }); + + if ($this->callHandleRequest()) { + $editor->handleRequest(ServerRequest::fromGlobals()); + } + + return $editor; + } + + /** + * Enrich the filter condition with meta data from the query + * + * @param Filter\Condition $condition + * @param Query $query + * + * @return void + */ + protected function enrichFilterCondition(Filter\Condition $condition, Query $query) + { + $path = $condition->getColumn(); + if (strpos($path, '.') === false) { + $path = $query->getResolver()->qualifyPath($path, $query->getModel()->getTableName()); + $condition->setColumn($path); + } + + try { + $label = $query->getResolver()->getColumnDefinition($path)->getLabel(); + } catch (InvalidRelationException $_) { + $label = null; + } + + if (isset($label)) { + $condition->metaData()->set('columnLabel', $label); + } + } +} diff --git a/vendor/ipl/web/src/Compat/ViewRenderer.php b/vendor/ipl/web/src/Compat/ViewRenderer.php new file mode 100644 index 0000000..48ddcc3 --- /dev/null +++ b/vendor/ipl/web/src/Compat/ViewRenderer.php @@ -0,0 +1,60 @@ +<?php + +namespace ipl\Web\Compat; + +use Zend_Controller_Action_Helper_ViewRenderer as Zf1ViewRenderer; +use Zend_Controller_Action_HelperBroker as Zf1HelperBroker; + +class ViewRenderer extends Zf1ViewRenderer +{ + /** + * Inject the view renderer + */ + public static function inject() + { + /** @var \Zend_Controller_Action_Helper_ViewRenderer $viewRenderer */ + $viewRenderer = Zf1HelperBroker::getStaticHelper('ViewRenderer'); + + $inject = new static(); + + foreach (get_object_vars($viewRenderer) as $property => $value) { + if ($property === '_inflector') { + continue; + } + + $inject->$property = $value; + } + + Zf1HelperBroker::removeHelper('ViewRenderer'); + Zf1HelperBroker::addHelper($inject); + } + + public function getName() + { + return 'ViewRenderer'; + } + + /** + * Render the view w/o using a view script + * + * {@inheritdoc} + */ + public function render($action = null, $name = null, $noController = null) + { + $view = $this->view; + + if ($view->document->isEmpty() || $this->getRequest()->getParam('error_handler') !== null) { + parent::render($action, $name, $noController); + + return; + } + + if ($name === null) { + $name = $this->getResponseSegment(); + } + + $this->getResponse()->appendBody($view->document->render(), $name); + + $this->setNoRender(); + } +} diff --git a/vendor/ipl/web/src/Control/LimitControl.php b/vendor/ipl/web/src/Control/LimitControl.php new file mode 100644 index 0000000..b390a0a --- /dev/null +++ b/vendor/ipl/web/src/Control/LimitControl.php @@ -0,0 +1,123 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; + +/** + * Allows to adjust the limit of the number of items to display + */ +class LimitControl extends CompatForm +{ + /** @var int Default limit */ + const DEFAULT_LIMIT = 25; + + /** @var string Default limit param */ + const DEFAULT_LIMIT_PARAM = 'limit'; + + /** @var int[] Selectable default limits */ + public static $limits = [ + '25' => '25', + '50' => '50', + '100' => '100', + '500' => '500' + ]; + + /** @var string Name of the URL parameter which stores the limit */ + protected $limitParam = self::DEFAULT_LIMIT_PARAM; + + /** @var int */ + protected $defaultLimit; + + /** @var Url */ + protected $url; + + protected $method = 'GET'; + + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Get the name of the URL parameter which stores the limit + * + * @return string + */ + public function getLimitParam() + { + return $this->limitParam; + } + + /** + * Set the name of the URL parameter which stores the limit + * + * @param string $limitParam + * + * @return $this + */ + public function setLimitParam($limitParam) + { + $this->limitParam = $limitParam; + + return $this; + } + + /** + * Get the default limit + * + * @return int + */ + public function getDefaultLimit() + { + return $this->defaultLimit ?: static::DEFAULT_LIMIT; + } + + /** + * Set the default limit + * + * @param int $limit + * + * @return $this + */ + public function setDefaultLimit($limit) + { + $this->defaultLimit = $limit; + + return $this; + } + + /** + * Get the limit + * + * @return int + */ + public function getLimit() + { + return $this->url->getParam($this->getLimitParam(), $this->getDefaultLimit()); + } + + protected function assemble() + { + $this->addAttributes(['class' => 'limit-control inline']); + + $limits = static::$limits; + if ($this->defaultLimit && ! isset($limits[$this->defaultLimit])) { + $limits[$this->defaultLimit] = $this->defaultLimit; + } + + $limit = $this->getLimit(); + if (! isset($limits[$limit])) { + $limits[$limit] = $limit; + } + + $this->addElement('select', $this->getLimitParam(), [ + 'class' => 'autosubmit', + 'label' => '#', + 'options' => $limits, + 'title' => t('Change item count per page'), + 'value' => $limit + ]); + } +} diff --git a/vendor/ipl/web/src/Control/PaginationControl.php b/vendor/ipl/web/src/Control/PaginationControl.php new file mode 100644 index 0000000..00f5c20 --- /dev/null +++ b/vendor/ipl/web/src/Control/PaginationControl.php @@ -0,0 +1,523 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * The pagination control displays a list of links that point to different pages of the current view + * + * The default HTML markup (tag and attributes) for the paginator look like the following: + * <div class="pagination-control" role="navigation">...</div> + */ +class PaginationControl extends BaseHtmlElement +{ + /** @var int Default maximum number of items which should be shown per page */ + protected $defaultPageSize; + + /** @var string Name of the URL parameter which stores the current page number */ + protected $pageParam = 'page'; + + /** @var string Name of the URL parameter which holds the page size. If given, overrides {@link $defaultPageSize} */ + protected $pageSizeParam = 'limit'; + + /** @var string */ + protected $pageSpacer = '…'; + + /** @var Paginatable The pagination adapter which handles the underlying data source */ + protected $paginatable; + + /** @var Url The URL to base off pagination URLs */ + protected $url; + + /** @var int Cache for the total number of items */ + private $totalCount; + + protected $tag = 'div'; + + protected $defaultAttributes = [ + 'class' => 'pagination-control', + 'role' => 'navigation' + ]; + + /** + * Create a pagination control + * + * @param Paginatable $paginatable The paginatable + * @param Url $url The URL to base off paging URLs + */ + public function __construct(Paginatable $paginatable, Url $url) + { + $this->paginatable = $paginatable; + $this->url = $url; + } + + /** + * Set the URL to base off paging URLs + * + * @param Url $url + * + * @return $this + */ + public function setUrl(Url $url) + { + $this->url = $url; + + return $this; + } + + /** + * Get the default page size + * + * @return int + */ + public function getDefaultPageSize() + { + return $this->defaultPageSize ?: LimitControl::DEFAULT_LIMIT; + } + + /** + * Set the default page size + * + * @param int $defaultPageSize + * + * @return $this + */ + public function setDefaultPageSize($defaultPageSize) + { + $this->defaultPageSize = $defaultPageSize; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the current page number + * + * @return string + */ + public function getPageParam() + { + return $this->pageParam; + } + + /** + * Set the name of the URL parameter which stores the current page number + * + * @param string $pageParam + * + * @return $this + */ + public function setPageParam($pageParam) + { + $this->pageParam = $pageParam; + + return $this; + } + + /** + * Get the name of the URL parameter which stores the page size + * + * @return string + */ + public function getPageSizeParam() + { + return $this->pageSizeParam; + } + /** + * Set the name of the URL parameter which stores the page size + * + * @param string $pageSizeParam + * + * @return $this + */ + public function setPageSizeParam($pageSizeParam) + { + $this->pageSizeParam = $pageSizeParam; + + return $this; + } + + /** + * Get the total number of items + * + * @return int + */ + public function getTotalCount() + { + if ($this->totalCount === null) { + $this->totalCount = $this->paginatable->count(); + } + + return $this->totalCount; + } + + /** + * Get the current page number + * + * @return int + */ + public function getCurrentPageNumber() + { + return (int) $this->url->getParam($this->pageParam, 1); + } + + /** + * Get the configured page size + * + * @return int + */ + public function getPageSize() + { + return (int) $this->url->getParam($this->pageSizeParam, $this->getDefaultPageSize()); + } + + /** + * Get the total page count + * + * @return int + */ + public function getPageCount() + { + $pageSize = $this->getPageSize(); + + if ($pageSize === 0) { + return 0; + } + + if ($pageSize < 0) { + return 1; + } + + return (int) ceil($this->getTotalCount() / $pageSize); + } + + /** + * Get the limit + * + * Use this method to set the LIMIT part of a query for fetching the current page. + * + * @return int If the page size is infinite, -1 will be returned + */ + public function getLimit() + { + $pageSize = $this->getPageSize(); + + return $pageSize < 0 ? -1 : $pageSize; + } + + /** + * Get the offset + * + * Use this method to set the OFFSET part of a query for fetching the current page. + * + * @return int + */ + public function getOffset() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $pageSize = $this->getPageSize(); + + return $currentPageNumber <= 1 ? 0 : ($currentPageNumber - 1) * $pageSize; + } + + /** + * Apply limit and offset on the paginatable + * + * @return $this + */ + public function apply() + { + $this->paginatable->limit($this->getLimit()); + $this->paginatable->offset($this->getOffset()); + + return $this; + } + + /** + * Create a URL for paging from the given page number + * + * @param int $pageNumber The page number + * @param int $pageSize The number of items per page. If you want to stick to the defaults, + * don't set this parameter + * + * @return Url + */ + public function createUrl($pageNumber, $pageSize = null) + { + $params = [$this->getPageParam() => $pageNumber]; + + if ($pageSize !== null) { + $params[$this->getPageSizeParam()] = $pageSize; + } + + return $this->url->with($params); + } + + /** + * Get the first item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getFirstItemNumberOfPage($pageNumber) + { + return ($pageNumber - 1) * $this->getPageSize() + 1; + } + + /** + * Get the last item number of the given page + * + * @param int $pageNumber + * + * @return int + */ + protected function getLastItemNumberOfPage($pageNumber) + { + return min($pageNumber * $this->getPageSize(), $this->getTotalCount()); + } + + /** + * Create the label for the given page number + * + * @param int $pageNumber + * + * @return string + */ + protected function createLabel($pageNumber) + { + return sprintf( + $this->translate('Show items %u to %u of %u'), + $this->getFirstItemNumberOfPage($pageNumber), + $this->getLastItemNumberOfPage($pageNumber), + $this->getTotalCount() + ); + } + + /** + * Create and return the previous page item + * + * @return BaseHtmlElement + */ + protected function createPreviousPageItem() + { + $prevIcon = new Icon('angle-left'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber > 1) { + $prevItem = Html::tag('li', ['class' => 'nav-item']); + + $prevItem->add(Html::tag( + 'a', + [ + 'class' => 'previous-page', + 'href' => $this->createUrl($currentPageNumber - 1), + 'title' => $this->createLabel($currentPageNumber - 1) + ], + $prevIcon + )); + } else { + $prevItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $prevItem->add(Html::tag('span', ['class' => 'previous-page'], $prevIcon)); + } + + return $prevItem; + } + + /** + * Create and return the next page item + * + * @return BaseHtmlElement + */ + protected function createNextPageItem() + { + $nextIcon = new Icon('angle-right'); + + $currentPageNumber = $this->getCurrentPageNumber(); + + if ($currentPageNumber < $this->getPageCount()) { + $nextItem = Html::tag('li', ['class' => 'nav-item']); + + $nextItem->add(Html::tag( + 'a', + [ + 'class' => 'next-page', + 'href' => $this->createUrl($currentPageNumber + 1), + 'title' => $this->createLabel($currentPageNumber + 1) + ], + $nextIcon + )); + } else { + $nextItem = Html::tag( + 'li', + [ + 'aria-hidden' => true, + 'class' => 'nav-item disabled' + ] + ); + + $nextItem->add(Html::tag('span', ['class' => 'next-page'], $nextIcon)); + } + + return $nextItem; + } + + /** @TODO(el): Use ipl-translation when it's ready instead */ + private function translate($message) + { + return $message; + } + + /** + * Create and return the first page item + * + * @return BaseHtmlElement + */ + protected function createFirstPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $url = clone $this->url; + + $firstItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === 1) { + $firstItem->addAttributes(['class' => 'disabled']); + $firstItem->add(Html::tag( + 'span', + ['class' => 'first-page'], + $this->getFirstItemNumberOfPage(1) + )); + } else { + $firstItem->add(Html::tag( + 'a', + [ + 'class' => 'first-page', + 'href' => $url->remove(['page'])->getAbsoluteUrl(), + 'title' => $this->createLabel(1) + ], + $this->getFirstItemNumberOfPage(1) + )); + } + + return $firstItem; + } + + /** + * Create and return the last page item + * + * @return BaseHtmlElement + */ + protected function createLastPageItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + $lastItem = Html::tag('li', ['class' => 'nav-item']); + + if ($currentPageNumber === $this->getPageCount()) { + $lastItem->addAttributes(['class' => 'disabled']); + $lastItem->add(Html::tag( + 'span', + ['class' => 'last-page'], + $this->getPageCount() + )); + } else { + $lastItem->add(Html::tag( + 'a', + [ + 'class' => 'last-page', + 'href' => $this->url->setParam('page', $this->getPageCount()), + 'title' => $this->createLabel($this->getPageCount()) + ], + $this->getPageCount() + )); + } + + return $lastItem; + } + + /** + * Create and return the page selector item + * + * @return BaseHtmlElement + */ + protected function createPageSelectorItem() + { + $currentPageNumber = $this->getCurrentPageNumber(); + + $form = new CompatForm($this->url); + $form->addAttributes(['class' => 'inline']); + $form->setMethod('GET'); + + $select = Html::tag('select', [ + 'name' => $this->getPageParam(), + 'class' => 'autosubmit', + 'title' => t('Go to page …') + ]); + + if (isset($currentPageNumber)) { + if ($currentPageNumber === 1 || $currentPageNumber === $this->getPageCount()) { + $select->add(Html::tag('option', ['disabled' => '', 'selected' => ''], '…')); + } + } + + foreach (range(2, $this->getPageCount() - 1) as $page) { + $option = Html::tag('option', [ + 'value' => $page + ], $page); + + if ($page == $currentPageNumber) { + $option->addAttributes(['selected' => '']); + } + + $select->add($option); + } + + $form->add($select); + + $pageSelectorItem = Html::tag('li', $form); + + return $pageSelectorItem; + } + + protected function assemble() + { + if ($this->getPageCount() < 2) { + return; + } + + // Accessibility info + $this->add(Html::tag( + 'h2', + [ + 'class' => 'sr-only', + 'tabindex' => '-1' + ], + $this->translate('Pagination') + )); + + $paginator = Html::tag('ul', ['class' => 'tab-nav nav']); + + $paginator->add([ + $this->createFirstPageItem(), + $this->createPreviousPageItem(), + $this->createPageSelectorItem(), + $this->createNextPageItem(), + $this->createLastPageItem() + ]); + + $this->add($paginator); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar.php b/vendor/ipl/web/src/Control/SearchBar.php new file mode 100644 index 0000000..2b18a83 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar.php @@ -0,0 +1,522 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\FormElement\SubmitElement; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Validator\CallbackValidator; +use ipl\Web\Common\FormUid; +use ipl\Web\Control\SearchBar\Terms; +use ipl\Web\Control\SearchBar\ValidatedColumn; +use ipl\Web\Control\SearchBar\ValidatedOperator; +use ipl\Web\Control\SearchBar\ValidatedValue; +use ipl\Web\Filter\ParseException; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchBar extends Form +{ + use FormUid; + + /** @var string Emitted in case the user added a new condition */ + const ON_ADD = 'on_add'; + + /** @var string Emitted in case the user inserted a new condition */ + const ON_INSERT = 'on_insert'; + + /** @var string Emitted in case the user changed an existing condition */ + const ON_SAVE = 'on_save'; + + /** @var string Emitted in case the user removed a condition */ + const ON_REMOVE = 'on_remove'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-bar', + 'class' => 'search-bar', + 'name' => 'search-bar', + 'role' => 'search' + ]; + + /** @var Url */ + protected $editorUrl; + + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $searchParameter; + + /** @var Url */ + protected $suggestionUrl; + + /** @var string */ + protected $submitLabel; + + /** @var callable */ + protected $protector; + + /** @var array */ + protected $changes; + + /** + * Set the url from which to load the editor + * + * @param Url $url + * + * @return $this + */ + public function setEditorUrl(Url $url) + { + $this->editorUrl = $url; + + return $this; + } + + /** + * Get the url from which to load the editor + * + * @return Url + */ + public function getEditorUrl() + { + return $this->editorUrl; + } + + /** + * Set the filter to use + * + * @param Filter\Rule $filter + * @return $this + */ + public function setFilter(Filter\Rule $filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Get the filter in use + * + * @return Filter\Rule + */ + public function getFilter() + { + return $this->filter; + } + + /** + * Set the search parameter to use + * + * @param string $name + * @return $this + */ + public function setSearchParameter($name) + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Get the search parameter in use + * + * @return string + */ + public function getSearchParameter() + { + return $this->searchParameter ?: 'q'; + } + + /** + * Set the suggestion url + * + * @param Url $url + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return Url + */ + public function getSuggestionUrl() + { + return $this->suggestionUrl; + } + + /** + * Set the submit label + * + * @param string $label + * @return $this + */ + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Get the submit label + * + * @return string + */ + public function getSubmitLabel() + { + return $this->submitLabel; + } + + /** + * Set callback to protect ids with + * + * @param callable $protector + * + * @return $this + */ + public function setIdProtector($protector) + { + $this->protector = $protector; + + return $this; + } + + /** + * Get changes to be applied on the client + * + * @return array + */ + public function getChanges() + { + return $this->changes; + } + + private function protectId($id) + { + if (is_callable($this->protector)) { + return call_user_func($this->protector, $id); + } + + return $id; + } + + public function isValidEvent($event) + { + switch ($event) { + case self::ON_ADD: + case self::ON_SAVE: + case self::ON_INSERT: + case self::ON_REMOVE: + return true; + default: + return parent::isValidEvent($event); + } + } + + private function validateCondition($eventType, $indices, $termsData, &$changes) + { + // TODO: In case of the query string validation, all three are guaranteed to be set. + // The Parser also provides defaults, why shouldn't we here? + $column = ValidatedColumn::fromTermData($termsData[0]); + $operator = isset($termsData[1]) + ? ValidatedOperator::fromTermData($termsData[1]) + : null; + $value = isset($termsData[2]) + ? ValidatedValue::fromTermData($termsData[2]) + : null; + + $this->emit($eventType, [$column, $operator, $value]); + + if ($eventType !== self::ON_REMOVE) { + if (! $column->isValid() || $column->hasBeenChanged()) { + $changes[$indices[0]] = array_merge($termsData[0], $column->toTermData()); + } + + if ($operator && ! $operator->isValid()) { + $changes[$indices[1]] = array_merge($termsData[1], $operator->toTermData()); + } + + if ($value && (! $value->isValid() || $value->hasBeenChanged())) { + $changes[$indices[2]] = array_merge($termsData[2], $value->toTermData()); + } + } + + return $column->isValid() && (! $operator || $operator->isValid()) && (! $value || $value->isValid()); + } + + + protected function assemble() + { + $termContainerId = $this->protectId('terms'); + $termInputId = $this->protectId('term-input'); + $dataInputId = $this->protectId('data-input'); + $searchInputId = $this->protectId('search-input'); + $suggestionsId = $this->protectId('suggestions'); + + $termContainer = (new Terms())->setAttribute('id', $termContainerId); + $termInput = new HiddenElement($this->getSearchParameter(), [ + 'id' => $termInputId, + 'disabled' => true + ]); + + if (! $this->getRequest()->getHeaderLine('X-Icinga-Autorefresh')) { + $termContainer->setFilter(function () { + return $this->getFilter(); + }); + $termInput->getAttributes()->registerAttributeCallback('value', function () { + return QueryString::render($this->getFilter()); + }); + } + + $dataInput = new HiddenElement('data', [ + 'id' => $dataInputId, + 'validators' => [ + new CallbackValidator(function ($data, CallbackValidator $_) use ($termContainer, $searchInputId) { + $data = $data ? json_decode($data, true) : null; + if (empty($data)) { + return true; + } + + switch ($data['type']) { + case 'add': + case 'exchange': + $type = self::ON_ADD; + + break; + case 'insert': + $type = self::ON_INSERT; + + break; + case 'save': + $type = self::ON_SAVE; + + break; + case 'remove': + $type = self::ON_REMOVE; + + break; + default: + return true; + } + + $changes = []; + $invalid = false; + $indices = [null, null, null]; + $termsData = [null, null, null]; + foreach (isset($data['terms']) ? $data['terms'] : [] as $termIndex => $termData) { + switch ($termData['type']) { + case 'column': + $indices[0] = $termIndex; + $termsData[0] = $termData; + + break; + case 'operator': + $indices[1] = $termIndex; + $termsData[1] = $termData; + + break; + case 'value': + $indices[2] = $termIndex; + $termsData[2] = $termData; + + break; + default: + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + $indices = $termsData = [null, null, null]; + } + } + + if ($termsData[0] !== null) { + if (! $this->validateCondition($type, $indices, $termsData, $changes)) { + $invalid = true; + } + } + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + $termContainer->applyChanges($changes); + } + + return ! $invalid; + }) + ] + ]); + $this->registerElement($dataInput); + + $filterInput = new InputElement($this->getSearchParameter(), [ + 'type' => 'text', + 'placeholder' => 'Type to search. Use * as wildcard.', + 'class' => 'filter-input', + 'id' => $searchInputId, + 'autocomplete' => 'off', + 'data-enrichment-type' => 'filter', + 'data-data-input' => '#' . $dataInputId, + 'data-term-input' => '#' . $termInputId, + 'data-term-container' => '#' . $termContainerId, + 'data-term-suggestions' => '#' . $suggestionsId, + 'data-missing-log-op' => t('Please add a logical operator on the left.'), + 'data-incomplete-group' => t('Please close or remove this group.'), + 'data-choose-template' => t('Please type one of: %s', '..<comma separated list>'), + 'data-choose-column' => t('Please enter a valid column.'), + 'validators' => [ + new CallbackValidator(function ($q, CallbackValidator $validator) use ($searchInputId) { + $submitted = $this->hasBeenSubmitted(); + $invalid = false; + $changes = []; + + $parser = QueryString::fromString($q); + $parser->on(QueryString::ON_CONDITION, function (Filter\Condition $condition) use ( + &$invalid, + &$changes, + $submitted + ) { + $columnIndex = $condition->metaData()->get('columnIndex'); + if (isset($this->changes[1][$columnIndex])) { + $change = $this->changes[1][$columnIndex]; + $condition->setColumn($change['search']); + } elseif (empty($this->changes)) { + $column = ValidatedColumn::fromFilterCondition($condition); + $operator = ValidatedOperator::fromFilterCondition($condition); + $value = ValidatedValue::fromFilterCondition($condition); + $this->emit(self::ON_ADD, [$column, $operator, $value]); + + $condition->setColumn($column->getSearchValue()); + $condition->setValue($value->getSearchValue()); + + if (! $column->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($column->toMetaData()); + } else { + $changes[$columnIndex] = $column->toTermData(); + } + } + + if (! $operator->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($operator->toMetaData()); + } else { + $changes[$condition->metaData()->get('operatorIndex')] = $operator->toTermData(); + } + } + + if (! $value->isValid()) { + $invalid = true; + + if ($submitted) { + $condition->metaData()->merge($value->toMetaData()); + } else { + $changes[$condition->metaData()->get('valueIndex')] = $value->toTermData(); + } + } + } + }); + + try { + $filter = $parser->parse(); + } catch (ParseException $e) { + $charAt = $e->getCharPos() - 1; + $char = $e->getChar(); + + $this->getElement($this->getSearchParameter()) + ->setValue(substr($q, $charAt)) + ->addAttributes([ + 'title' => sprintf(t('Unexpected %s at start of input'), $char), + 'pattern' => sprintf('^(?!%s).*', $char === ')' ? '\)' : $char), + 'data-has-syntax-error' => true + ]); + + $probablyValidQueryString = substr($q, 0, $charAt); + $this->setFilter(QueryString::parse($probablyValidQueryString)); + return false; + } + + $this->getElement($this->getSearchParameter())->setValue(''); + $this->setFilter($filter); + + if (! empty($changes)) { + $this->changes = ['#' . $searchInputId, $changes]; + } + + return ! $invalid; + }) + ] + ]); + if ($this->getSuggestionUrl() !== null) { + $filterInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + } + + $this->registerElement($filterInput); + + $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel() ?: 'hidden']); + $this->registerElement($submitButton); + + $editorOpener = null; + if ($this->getEditorUrl() !== null) { + $editorOpener = new HtmlElement( + 'button', + Attributes::create([ + 'type' => 'button', + 'class' => 'search-editor-opener control-button', + 'title' => t('Adjust Filter') + ])->registerAttributeCallback('data-search-editor-url', function () { + return (string) $this->getEditorUrl(); + }), + new Icon('cog') + ); + } + + $this->addHtml( + new HtmlElement( + 'button', + Attributes::create(['type' => 'button', 'class' => 'search-options']), + new Icon('search') + ), + new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-input-area']), + $termContainer, + new HtmlElement('label', Attributes::create(['data-label' => '']), $filterInput) + ), + $dataInput, + $termInput, + $submitButton, + $this->createUidElement(), + new HtmlElement('div', Attributes::create([ + 'id' => $suggestionsId, + 'class' => 'search-suggestions', + 'data-base-target' => $suggestionsId + ])) + ); + + // Render the editor container outside of this form. It will contain a form as well later on + // loaded by XHR and HTML prohibits nested forms. It's style-wise also better... + $doc = new HtmlDocument(); + $this->setWrapper($doc); + $doc->addHtml($this, ...($editorOpener ? [$editorOpener] : [])); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/SearchException.php b/vendor/ipl/web/src/Control/SearchBar/SearchException.php new file mode 100644 index 0000000..a89c6ce --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/SearchException.php @@ -0,0 +1,9 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Exception; + +class SearchException extends Exception +{ +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Suggestions.php b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php new file mode 100644 index 0000000..eae4c97 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Suggestions.php @@ -0,0 +1,447 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use Countable; +use Generator; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormattedString; +use ipl\Html\FormElement\ButtonElement; +use ipl\Html\FormElement\InputElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Contract\Paginatable; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use IteratorIterator; +use LimitIterator; +use OuterIterator; +use Psr\Http\Message\ServerRequestInterface; +use Traversable; + +use function ipl\I18n\t; + +abstract class Suggestions extends BaseHtmlElement +{ + const DEFAULT_LIMIT = 50; + const SUGGESTION_TITLE_CLASS = 'suggestion-title'; + + protected $tag = 'ul'; + + /** @var string */ + protected $searchTerm; + + /** @var Traversable */ + protected $data; + + /** @var array */ + protected $default; + + /** @var string */ + protected $type; + + /** @var string */ + protected $failureMessage; + + public function setSearchTerm($term) + { + $this->searchTerm = $term; + + return $this; + } + + public function setData($data) + { + $this->data = $data; + + return $this; + } + + public function setDefault($default) + { + $this->default = $default; + + return $this; + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setFailureMessage($message) + { + $this->failureMessage = $message; + + return $this; + } + + /** + * Return whether the relation should be shown for the given column + * + * @param string $column + * + * @return bool + */ + protected function shouldShowRelationFor(string $column): bool + { + return false; + } + + /** + * Create a filter to provide as default for column suggestions + * + * @param string $searchTerm + * + * @return Filter\Rule + */ + abstract protected function createQuickSearchFilter($searchTerm); + + /** + * Fetch value suggestions for a particular column + * + * @param string $column + * @param string $searchTerm + * @param Filter\Chain $searchFilter + * + * @return Traversable + */ + abstract protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter); + + /** + * Fetch column suggestions + * + * @param string $searchTerm + * + * @return Traversable + */ + abstract protected function fetchColumnSuggestions($searchTerm); + + protected function filterToTerms(Filter\Chain $filter) + { + $logicalSep = [ + 'label' => QueryString::getRuleSymbol($filter), + 'search' => QueryString::getRuleSymbol($filter), + 'class' => 'logical_operator', + 'type' => 'logical_operator' + ]; + + $terms = []; + foreach ($filter as $child) { + if ($child instanceof Filter\Chain) { + $terms[] = [ + 'search' => '(', + 'label' => '(', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_open' + ]; + $terms = array_merge($terms, $this->filterToTerms($child)); + $terms[] = [ + 'search' => ')', + 'label' => ')', + 'type' => 'grouping_operator', + 'class' => 'grouping_operator_close' + ]; + } else { + /** @var Filter\Condition $child */ + + $terms[] = [ + 'search' => $child->getColumn(), + 'label' => $child->metaData()->get('columnLabel'), + 'type' => 'column' + ]; + $terms[] = [ + 'search' => QueryString::getRuleSymbol($child), + 'label' => QueryString::getRuleSymbol($child), + 'type' => 'operator' + ]; + $terms[] = [ + 'search' => $child->getValue(), + 'label' => $child->getValue(), + 'type' => 'value' + ]; + } + + $terms[] = $logicalSep; + } + + array_pop($terms); + return $terms; + } + + protected function assembleDefault() + { + if ($this->default === null) { + return; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-label' => $this->default['search'], + 'value' => $this->default['search'] + ]; + if (isset($this->default['type'])) { + $attributes['data-type'] = $this->default['type']; + } elseif ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + $button = new ButtonElement(null, $attributes); + if (isset($this->default['type']) && $this->default['type'] === 'terms') { + $terms = $this->filterToTerms($this->default['terms']); + $list = new HtmlElement('ul', Attributes::create(['class' => 'comma-separated'])); + foreach ($terms as $data) { + if ($data['type'] === 'column') { + $list->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement('em', null, Text::create($data['label'])) + )); + } + } + + $button->setAttribute('data-terms', json_encode($terms)); + $button->addHtml(FormattedString::create( + t('Search for %s in: %s'), + new HtmlElement('em', null, Text::create($this->default['search'])), + $list + )); + } else { + $button->addHtml(FormattedString::create( + t('Search for %s'), + new HtmlElement('em', null, Text::create($this->default['search'])) + )); + } + + $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button)); + } + + protected function assemble() + { + if ($this->failureMessage !== null) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'failure-message']), + new HtmlElement('em', null, Text::create(t('Can\'t search:'))), + Text::create($this->failureMessage) + )); + return; + } + + if ($this->data === null) { + $data = []; + } elseif ($this->data instanceof Paginatable) { + $this->data->limit(self::DEFAULT_LIMIT); + $data = $this->data; + } else { + $data = new LimitIterator(new IteratorIterator($this->data), 0, self::DEFAULT_LIMIT); + } + + foreach ($data as $term => $meta) { + if (is_int($term)) { + $term = $meta; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-search' => $term, + 'data-title' => $term + ]; + if ($this->type !== null) { + $attributes['data-type'] = $this->type; + } + + if (is_array($meta)) { + foreach ($meta as $key => $value) { + if ($key === 'label') { + $label = $value; + } + + $attributes['data-' . $key] = $value; + } + } else { + $label = $meta; + $attributes['data-label'] = $meta; + } + + $button = (new ButtonElement(null, $attributes)) + ->setAttribute('value', $label) + ->addHtml(Text::create($label)); + if ($this->type === 'column' && $this->shouldShowRelationFor($term)) { + $relationPath = substr($term, 0, strrpos($term, '.')); + $button->getAttributes()->add('class', 'has-details'); + $button->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => 'relation-path']), + Text::create($relationPath) + )); + } + + $this->addHtml(new HtmlElement('li', null, $button)); + } + + if ($this->hasMore($data, self::DEFAULT_LIMIT)) { + $this->getAttributes()->add('class', 'has-more'); + } + + $showDefault = true; + if ($this->searchTerm && $this->count() === 1) { + // The default option is only shown if the user's input does not result in an exact match + $input = $this->getFirst('li')->getFirst('button'); + $showDefault = $input->getContent() != $this->searchTerm + && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm; + } + + if ($this->type === 'column' && ! $this->isEmpty() && ! $this->getFirst('li')->getAttributes()->has('class')) { + // The column title is only added if there are any suggestions and the first item is not a title already + $this->prependHtml(new HtmlElement( + 'li', + Attributes::create(['class' => static::SUGGESTION_TITLE_CLASS]), + Text::create(t('Columns')) + )); + } + + if ($showDefault) { + $this->assembleDefault(); + } + + if (! $this->searchTerm && $this->isEmpty()) { + $this->addHtml(new HtmlElement( + 'li', + Attributes::create(['class' => 'nothing-to-suggest']), + new HtmlElement('em', null, Text::create(t('Nothing to suggest'))) + )); + } + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request) + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $search = $requestData['term']['search']; + $label = $requestData['term']['label']; + $type = $requestData['term']['type']; + + $this->setSearchTerm($search); + $this->setType($type); + + switch ($type) { + case 'value': + if (! $requestData['column'] || $requestData['column'] === SearchEditor::FAKE_COLUMN) { + $this->setFailureMessage(t('Missing column name')); + break; + } + + $searchFilter = QueryString::parse( + isset($requestData['searchFilter']) + ? $requestData['searchFilter'] + : '' + ); + if ($searchFilter instanceof Filter\Condition) { + $searchFilter = Filter::all($searchFilter); + } + + try { + $this->setData($this->fetchValueSuggestions($requestData['column'], $label, $searchFilter)); + } catch (SearchException $e) { + $this->setFailureMessage($e->getMessage()); + } + + if ($search) { + $this->setDefault(['search' => $label]); + } + + break; + case 'column': + $this->setData($this->filterColumnSuggestions($this->fetchColumnSuggestions($label), $label)); + + if ($search && isset($requestData['showQuickSearch']) && $requestData['showQuickSearch']) { + $quickFilter = $this->createQuickSearchFilter($label); + if (! $quickFilter instanceof Filter\Chain || ! $quickFilter->isEmpty()) { + $this->setDefault([ + 'search' => $label, + 'type' => 'terms', + 'terms' => $quickFilter + ]); + } + } + } + + return $this; + } + + protected function hasMore($data, $than) + { + if (is_array($data)) { + return count($data) > $than; + } elseif ($data instanceof Countable) { + return $data->count() > $than; + } elseif ($data instanceof OuterIterator) { + return $this->hasMore($data->getInnerIterator(), $than); + } + + return false; + } + + /** + * Filter the given suggestions by the client's input + * + * @param Traversable $data + * @param string $searchTerm + * + * @return Generator + */ + protected function filterColumnSuggestions($data, $searchTerm) + { + foreach ($data as $key => $value) { + if ($this->matchSuggestion($key, $value, $searchTerm)) { + yield $key => $value; + } + } + } + + /** + * Get whether the given suggestion should be provided to the client + * + * @param string $path + * @param string $label + * @param string $searchTerm + * + * @return bool + */ + protected function matchSuggestion($path, $label, $searchTerm) + { + return fnmatch($searchTerm, $label, FNM_CASEFOLD) || fnmatch($searchTerm, $path, FNM_CASEFOLD); + } + + public function renderUnwrapped() + { + $this->ensureAssembled(); + + if ($this->isEmpty()) { + return ''; + } + + return parent::renderUnwrapped(); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/Terms.php b/vendor/ipl/web/src/Control/SearchBar/Terms.php new file mode 100644 index 0000000..c81e336 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/Terms.php @@ -0,0 +1,255 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Filter\QueryString; +use ipl\Web\Widget\Icon; + +class Terms extends BaseHtmlElement +{ + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'terms']; + + /** @var callable|Filter\Rule */ + protected $filter; + + /** @var array */ + protected $changes; + + /** @var int */ + private $changeIndexCorrection = 0; + + /** @var int */ + private $currentIndex = 0; + + public function setFilter($filter) + { + $this->filter = $filter; + + return $this; + } + + /** + * Apply term changes + * + * @param array $changes + * + * @return $this + */ + public function applyChanges(array $changes) + { + $this->changes = $changes; + + return $this; + } + + protected function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); + } + + if ($filter === null) { + return; + } + + if ($filter instanceof Filter\Chain) { + if ($filter->isEmpty()) { + return; + } + + if ($filter instanceof Filter\None) { + $this->assembleChain($filter, $this, $filter->count() > 1); + } else { + $this->assembleConditions($filter, $this); + } + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $this); + } + } + + protected function assembleConditions(Filter\Chain $filters, BaseHtmlElement $where) + { + foreach ($filters as $i => $filter) { + if ($i > 0) { + $logicalOperator = QueryString::getRuleSymbol($filters); + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'logical_operator', + 'search' => $logicalOperator, + 'label' => $logicalOperator + ], $where); + } + + if ($filter instanceof Filter\Chain) { + $this->assembleChain($filter, $where, $filter->count() > 1); + } else { + /** @var Filter\Condition $filter */ + $this->assembleCondition($filter, $where); + } + } + } + + protected function assembleChain(Filter\Chain $chain, BaseHtmlElement $where, $wrap = false) + { + if ($wrap) { + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-chain', 'data-group-type' => 'chain']) + ); + } else { + $group = $where; + } + + if ($chain instanceof Filter\None) { + $this->assembleTerm([ + 'class' => 'logical_operator', + 'type' => 'negation_operator', + 'search' => '!', + 'label' => '!' + ], $where); + } + + if ($wrap) { + $opening = $this->assembleTerm([ + 'class' => 'grouping_operator_open', + 'type' => 'grouping_operator', + 'search' => '(', + 'label' => '(' + ], $group); + } + + $this->assembleConditions($chain, $group); + + if ($wrap) { + $closing = $this->assembleTerm([ + 'class' => 'grouping_operator_close', + 'type' => 'grouping_operator', + 'search' => ')', + 'label' => ')' + ], $group); + + $opening->addAttributes([ + 'data-counterpart' => $closing->getAttributes()->get('data-index')->getValue() + ]); + $closing->addAttributes([ + 'data-counterpart' => $opening->getAttributes()->get('data-index')->getValue() + ]); + + $where->addHtml($group); + } + } + + protected function assembleCondition(Filter\Condition $filter, BaseHtmlElement $where) + { + $column = $filter->getColumn(); + $operator = QueryString::getRuleSymbol($filter); + $value = $filter->getValue(); + $columnLabel = $filter->metaData()->get('columnLabel', $column); + + $group = new HtmlElement( + 'div', + Attributes::create(['class' => 'filter-condition', 'data-group-type' => 'condition']), + new HtmlElement('button', Attributes::create(['type' => 'button']), new Icon('trash')) + ); + + $columnData = [ + 'class' => 'column', + 'type' => 'column', + 'search' => rawurlencode($column), + 'label' => $columnLabel, + 'title' => $column + ]; + if ($filter->metaData()->has('invalidColumnPattern')) { + $columnData['pattern'] = $filter->metaData()->get('invalidColumnPattern'); + if ($filter->metaData()->has('invalidColumnMessage')) { + $columnData['invalidMsg'] = $filter->metaData()->get('invalidColumnMessage'); + } + } + + $this->assembleTerm($columnData, $group); + + if ($value !== true) { + $operatorData = [ + 'class' => 'operator', + 'type' => 'operator', + 'search' => $operator, + 'label' => $operator + ]; + if ($filter->metaData()->has('invalidOperatorPattern')) { + $operatorData['pattern'] = $filter->metaData()->get('invalidOperatorPattern'); + if ($filter->metaData()->has('invalidOperatorMessage')) { + $operatorData['invalidMsg'] = $filter->metaData()->get('invalidOperatorMessage'); + } + } + + $this->assembleTerm($operatorData, $group); + + if (! empty($value) || ! is_string($value) || ctype_digit($value)) { + $valueData = [ + 'class' => 'value', + 'type' => 'value', + 'search' => rawurlencode($value), + 'label' => $value + ]; + if ($filter->metaData()->has('invalidValuePattern')) { + $valueData['pattern'] = $filter->metaData()->get('invalidValuePattern'); + if ($filter->metaData()->has('invalidValueMessage')) { + $valueData['invalidMsg'] = $filter->metaData()->get('invalidValueMessage'); + } + } + + $this->assembleTerm($valueData, $group); + } + } + + $where->addHtml($group); + } + + protected function assembleTerm(array $data, BaseHtmlElement $where) + { + if (isset($this->changes[$this->currentIndex - $this->changeIndexCorrection])) { + $change = $this->changes[$this->currentIndex - $this->changeIndexCorrection]; + if ($change['type'] !== $data['type']) { + // This can happen because the user didn't insert parentheses but the parser did + $this->changeIndexCorrection++; + } else { + $data = array_merge($data, $change); + } + } + + $term = new HtmlElement('label', Attributes::create([ + 'class' => $data['class'], + 'data-index' => $this->currentIndex++, + 'data-type' => $data['type'], + 'data-search' => $data['search'], + 'data-label' => $data['label'] + ]), new HtmlElement('input', Attributes::create([ + 'type' => 'text', + 'value' => $data['label'] + ]))); + + if (isset($data['title'])) { + $term->setAttribute('title', $data['title']); + } + + if (isset($data['pattern'])) { + $term->getFirst('input')->setAttribute('pattern', $data['pattern']); + + if (isset($data['invalidMsg'])) { + $term->getFirst('input')->setAttribute('data-invalid-msg', $data['invalidMsg']); + } + } + + $where->addHtml($term); + + return $term; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php new file mode 100644 index 0000000..5825790 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedColumn.php @@ -0,0 +1,44 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedColumn extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getColumn(), $condition->metaData()->get('columnLabel')); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'column'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + if (($label = $this->getLabel()) !== null) { + $data->set('columnLabel', $label); + } + + if (! $this->isValid()) { + $data->set('invalidColumnMessage', $this->getMessage()) + ->set('invalidColumnPattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php new file mode 100644 index 0000000..2ca6d77 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedOperator.php @@ -0,0 +1,80 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use InvalidArgumentException; +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter; +use LogicException; + +class ValidatedOperator extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Filter\Condition $condition + * + * @return static + * + * @throws InvalidArgumentException In case the condition type is unknown + */ + public static function fromFilterCondition(Filter\Condition $condition) + { + switch (true) { + case $condition instanceof Filter\Unlike: + case $condition instanceof Filter\Unequal: + $operator = '!='; + break; + case $condition instanceof Filter\Like: + case $condition instanceof Filter\Equal: + $operator = '='; + break; + case $condition instanceof Filter\GreaterThan: + $operator = '>'; + break; + case $condition instanceof Filter\LessThan: + $operator = '<'; + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $operator = '>='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $operator = '<='; + break; + default: + throw new InvalidArgumentException('Unknown condition type'); + } + + return new static($operator); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'operator'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidOperatorMessage', $this->getMessage()) + ->set('invalidOperatorPattern', $this->getPattern()); + } + + return $data; + } + + public function setSearchValue($searchValue) + { + throw new LogicException('Operators cannot be changed'); + } + + public function setLabel($label) + { + throw new LogicException('Operators cannot be changed'); + } +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php new file mode 100644 index 0000000..e552552 --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedTerm.php @@ -0,0 +1,196 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; + +abstract class ValidatedTerm +{ + /** @var string The default validation constraint */ + const DEFAULT_PATTERN = '^\s*(?!%s\b).*\s*$'; + + /** @var mixed The search value */ + protected $searchValue; + + /** @var string The label */ + protected $label; + + /** @var string The validation message */ + protected $message; + + /** @var string The validation constraint */ + protected $pattern; + + /** @var bool Whether the term has been adjusted */ + protected $changed = false; + + /** + * Create a new ValidatedTerm + * + * @param mixed $searchValue The search value + * @param ?string $label The label + */ + public function __construct($searchValue, $label = null) + { + $this->searchValue = $searchValue; + $this->label = $label; + } + + /** + * Create a new ValidatedTerm from the given data + * + * @param array $data + * + * @return static + */ + public static function fromTermData(array $data) + { + return new static($data['search'], isset($data['label']) ? $data['label'] : null); + } + + /** + * Check whether the term is valid + * + * @return bool + */ + public function isValid() + { + return $this->message === null; + } + + /** + * Check whether the term has been adjusted + * + * @return bool + */ + public function hasBeenChanged() + { + return $this->changed; + } + + /** + * Get the search value + * + * @return mixed + */ + public function getSearchValue() + { + return $this->searchValue; + } + + /** + * Set the search value + * + * @param mixed $searchValue + * + * @return $this + */ + public function setSearchValue($searchValue) + { + $this->searchValue = $searchValue; + $this->changed = true; + + return $this; + } + + /** + * Get the label + * + * @return string + */ + public function getLabel() + { + return $this->label; + } + + /** + * Set the label + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = (string) $label; + $this->changed = true; + + return $this; + } + + /** + * Get the validation message + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Set the validation message + * + * @param ?string $message + * + * @return $this + */ + public function setMessage($message) + { + $this->message = $message; + + return $this; + } + + /** + * Get the validation constraint + * + * Returns the default constraint if none is set. + * + * @return string + */ + public function getPattern() + { + if ($this->pattern === null) { + return sprintf(self::DEFAULT_PATTERN, $this->getSearchValue()); + } + + return $this->pattern; + } + + /** + * Set the validation constraint + * + * @param string $pattern + * + * @return $this + */ + public function setPattern($pattern) + { + $this->pattern = (string) $pattern; + + return $this; + } + + /** + * Get this term's data + * + * @return array + */ + public function toTermData() + { + return [ + 'search' => $this->getSearchValue(), + 'label' => $this->getLabel() ?: $this->getSearchValue(), + 'invalidMsg' => $this->getMessage(), + 'pattern' => $this->getPattern() + ]; + } + + /** + * Get this term's metadata + * + * @return Data + */ + abstract public function toMetaData(); +} diff --git a/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php new file mode 100644 index 0000000..423102d --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchBar/ValidatedValue.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Web\Control\SearchBar; + +use ipl\Stdlib\Data; +use ipl\Stdlib\Filter\Condition; + +class ValidatedValue extends ValidatedTerm +{ + /** + * Create a new ValidatedColumn from the given filter condition + * + * @param Condition $condition + * + * @return static + */ + public static function fromFilterCondition(Condition $condition) + { + return new static($condition->getValue()); + } + + public function toTermData() + { + $termData = parent::toTermData(); + $termData['type'] = 'value'; + + return $termData; + } + + public function toMetaData() + { + $data = new Data(); + + if (! $this->isValid()) { + $data->set('invalidValueMessage', $this->getMessage()) + ->set('invalidValuePattern', $this->getPattern()); + } + + return $data; + } +} diff --git a/vendor/ipl/web/src/Control/SearchEditor.php b/vendor/ipl/web/src/Control/SearchEditor.php new file mode 100644 index 0000000..dfcde2e --- /dev/null +++ b/vendor/ipl/web/src/Control/SearchEditor.php @@ -0,0 +1,617 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\FormDecorator\CallbackDecorator; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Filter\Parser; +use ipl\Web\Filter\QueryString; +use ipl\Web\Filter\Renderer; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +class SearchEditor extends Form +{ + use Events; + + /** @var string Emitted for every validated column */ + const ON_VALIDATE_COLUMN = 'validate-column'; + + /** @var string The column name used for empty conditions */ + const FAKE_COLUMN = '_fake_'; + + protected $defaultAttributes = [ + 'data-enrichment-type' => 'search-editor', + 'class' => 'search-editor' + ]; + + /** @var string */ + protected $queryString; + + /** @var Url */ + protected $suggestionUrl; + + /** @var Parser */ + protected $parser; + + /** @var Filter\Rule */ + protected $filter; + + /** @var bool */ + protected $cleared = false; + + /** + * Set the filter query string to populate the form with + * + * Use {@see SearchEditor::getParser()} to subscribe to parser events. + * + * @param string $query + * + * @return $this + */ + public function setQueryString($query) + { + $this->queryString = $query; + + return $this; + } + + /** + * Get the suggestion url + * + * @return ?Url + */ + public function getSuggestionUrl(): ?Url + { + return $this->suggestionUrl; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url) + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the query string parser being used + * + * @return Parser + */ + public function getParser() + { + if ($this->parser === null) { + $this->parser = new Parser(); + } + + return $this->parser; + } + + /** + * Get the current filter + * + * @return Filter\Rule + */ + public function getFilter() + { + if ($this->filter === null) { + $this->filter = $this->getParser() + ->setQueryString($this->queryString) + ->parse(); + } + + return $this->filter; + } + + public function populate($values) + { + // applyChanges() is basically this form's own populate implementation, hence + // why it changes $values and needs to run before actually populating the form + $filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString)) + ->setStrict() + ->parse(); + $filter = $this->applyChanges($filter, $values); + + parent::populate($values); + + $this->filter = $this->applyStructuralChange($filter); + if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) { + $this->queryString = (new Renderer($this->filter))->setStrict()->render(); + } else { + $this->queryString = ''; + } + + return $this; + } + + public function hasBeenSubmitted() + { + if (parent::hasBeenSubmitted()) { + return true; + } + + return $this->cleared; + } + + public function validate() + { + if ($this->cleared) { + $this->isValid = true; + } else { + parent::validate(); + } + + return $this; + } + + protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $newColumn = $this->popKey($values, $identifier . '-column-search'); + if ($newColumn === null) { + $newColumn = $this->popKey($values, $identifier . '-column'); + } else { + // Make sure we don't forget to present the column labels again + $rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column')); + } + + if ($newColumn !== null && $rule->getColumn() !== $newColumn) { + $rule->setColumn($newColumn ?: static::FAKE_COLUMN); + // TODO: Clear meta data? + } + + $newValue = $this->popKey($values, $identifier . '-value'); + $oldValue = $rule->getValue(); + if ($newValue !== null && $oldValue !== $newValue) { + $rule->setValue($newValue); + } + + $newOperator = $this->popKey($values, $identifier . '-operator'); + if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) { + $value = $rule->getValue(); + $column = $rule->getColumn(); + switch ($newOperator) { + case '=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::like($column, $value); + } + + return Filter::equal($column, $value); + case '!=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::unlike($column, $value); + } + + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + $value = $rule->getValue(); + if ($oldValue !== $value && is_string($value) && strpos($value, '*') !== false) { + if (QueryString::getRuleSymbol($rule) === '=') { + return Filter::like($rule->getColumn(), $value); + } elseif (QueryString::getRuleSymbol($rule) === '!=') { + return Filter::unlike($rule->getColumn(), $value); + } + } + } else { + /** @var Filter\Chain $rule */ + $newGroupOperator = $this->popKey($values, $identifier); + $oldGroupOperator = $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule); + if ($newGroupOperator !== null && $oldGroupOperator !== $newGroupOperator) { + switch ($newGroupOperator) { + case '&': + $rule = Filter::all(...$rule); + break; + case '|': + $rule = Filter::any(...$rule); + break; + case '!': + $rule = Filter::none(...$rule); + break; + } + } + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $newChild = $this->applyChanges($child, $values, $childPath); + if ($child !== $newChild) { + $rule->replace($child, $newChild); + } + } + } + + return $rule; + } + + protected function applyStructuralChange(Filter\Rule $rule) + { + $structuralChange = $this->getPopulatedValue('structural-change'); + if (empty($structuralChange)) { + return $rule; + } elseif (is_array($structuralChange)) { + ksort($structuralChange); + } + + list($type, $where) = explode(':', is_array($structuralChange) + ? array_shift($structuralChange) + : $structuralChange); + $targetPath = explode('-', substr($where, 5)); + + $targetFinder = function ($path) use ($rule) { + $parent = null; + $target = null; + $children = [$rule]; + foreach ($path as $targetPos) { + if ($target !== null) { + $parent = $target; + $children = $parent instanceof Filter\Chain + ? iterator_to_array($parent) + : []; + } + + if (! isset($children[$targetPos])) { + return [null, null]; + } + + $target = $children[$targetPos]; + } + + return [$parent, $target]; + }; + + list($parent, $target) = $targetFinder($targetPath); + if ($target === null) { + return $rule; + } + + $emptyEqual = Filter::equal(static::FAKE_COLUMN, ''); + switch ($type) { + case 'move-rule': + if (! is_array($structuralChange) || empty($structuralChange)) { + return $rule; + } + + list($placement, $moveToPath) = explode(':', array_shift($structuralChange)); + list($moveToParent, $moveToTarget) = $targetFinder(explode('-', substr($moveToPath, 5))); + + $parent->remove($target); + if ($placement === 'to') { + $moveToTarget->add($target); + } elseif ($placement === 'before') { + $moveToParent->insertBefore($target, $moveToTarget); + } else { + $moveToParent->insertAfter($target, $moveToTarget); + } + + break; + case 'add-condition': + $target->add($emptyEqual); + + break; + case 'add-group': + $target->add(Filter::all($emptyEqual)); + + break; + case 'wrap-rule': + if ($parent !== null) { + $parent->replace($target, Filter::all($target)); + } else { + $rule = Filter::all($target); + } + + break; + case 'drop-rule': + if ($parent !== null) { + $parent->remove($target); + } else { + $rule = $emptyEqual; + } + + break; + case 'clear': + $this->cleared = true; + $rule = null; + } + + return $rule; + } + + protected function createTree(Filter\Rule $rule, array $path = [0]) + { + $identifier = 'rule-' . join('-', $path); + + if ($rule instanceof Filter\Condition) { + $parts = [$this->createCondition($rule, $identifier), $this->createButtons($rule, $identifier)]; + + if (count($path) === 1) { + $item = new HtmlElement('ol', null, new HtmlElement( + 'li', + Attributes::create(['id' => $identifier]), + ...$parts + )); + } else { + array_splice($parts, 1, 0, [ + new Icon('bars', ['class' => 'drag-initiator']) + ]); + + $item = (new HtmlDocument())->addHtml(...$parts); + } + } else { + /** @var Filter\Chain $rule */ + $item = new HtmlElement('ul'); + + $groupOperatorInput = $this->createElement('select', $identifier, [ + 'options' => [ + '&' => 'ALL', + '|' => 'ANY', + '!' => 'NONE' + ], + 'value' => $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule) + ]); + $this->registerElement($groupOperatorInput); + $item->addHtml(HtmlElement::create('li', ['id' => $identifier], [ + $groupOperatorInput, + count($path) > 1 + ? new Icon('bars', ['class' => 'drag-initiator']) + : null, + $this->createButtons($rule, $identifier) + ])); + + $children = new HtmlElement('ol'); + $item->addHtml(new HtmlElement('li', null, $children)); + + $i = 0; + foreach ($rule as $child) { + $childPath = $path; + $childPath[] = $i++; + $children->addHtml(new HtmlElement( + 'li', + Attributes::create([ + 'id' => 'rule-' . join('-', $childPath), + 'class' => $child instanceof Filter\Condition + ? 'filter-condition' + : 'filter-chain' + ]), + $this->createTree($child, $childPath) + )); + } + } + + return $item; + } + + protected function createButtons(Filter\Rule $for, $identifier) + { + $buttons = []; + + if ($for instanceof Filter\Chain) { + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-condition:' . $identifier, + 'label' => t('Add Condition', 'to a group of filter conditions'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'add-group:' . $identifier, + 'label' => t('Add Group', 'of filter conditions'), + 'formnovalidate' => true + ]); + } + + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'wrap-rule:' . $identifier, + 'label' => t('Wrap in Group', 'a filter rule'), + 'formnovalidate' => true + ]); + $buttons[] = $this->createElement('submitButton', 'structural-change', [ + 'value' => 'drop-rule:' . $identifier, + 'label' => t('Delete', 'a filter rule'), + 'formnovalidate' => true + ]); + + $ul = new HtmlElement('ul'); + foreach ($buttons as $button) { + $ul->addHtml(new HtmlElement('li', null, $button)); + } + + return new HtmlElement( + 'div', + Attributes::create(['class' => 'buttons']), + $ul, + new Icon('ellipsis-h') + ); + } + + protected function createCondition(Filter\Condition $condition, $identifier) + { + $columnInput = $this->createElement('text', $identifier . '-column', [ + 'value' => $condition->metaData()->get( + 'columnLabel', + $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null + ), + 'title' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'required' => true, + 'autocomplete' => 'off', + 'data-type' => 'column', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $columnInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + (new CallbackDecorator(function ($element) { + $errors = new HtmlElement('ul', Attributes::create(['class' => 'search-errors'])); + + foreach ($element->getMessages() as $message) { + $errors->addHtml(new HtmlElement('li', null, Text::create($message))); + } + + if (! $errors->isEmpty()) { + if (trim($element->getValue())) { + $element->getAttributes()->add( + 'pattern', + sprintf( + '^\s*(?!%s\b).*\s*$', + $element->getValue() + ) + ); + } + + $element->prependWrapper(new HtmlElement( + 'div', + Attributes::create(['class' => 'search-error']), + $element, + $errors + )); + } + }))->decorate($columnInput); + + $columnFakeInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => static::FAKE_COLUMN + ]); + $columnSearchInput = $this->createElement('hidden', $identifier . '-column-search', [ + 'value' => $condition->getColumn() !== static::FAKE_COLUMN + ? $condition->getColumn() + : null, + 'validators' => ['Callback' => function ($value) use ($condition, $columnInput, &$columnSearchInput) { + if (! $this->hasBeenSubmitted()) { + return true; + } + + try { + $this->emit(static::ON_VALIDATE_COLUMN, [$condition]); + } catch (SearchException $e) { + $columnInput->addMessage($e->getMessage()); + return false; + } + + $columnSearchInput->setValue($condition->getColumn()); + $columnInput->setValue($condition->metaData()->get('columnLabel', $condition->getColumn())); + + return true; + }] + ]); + + $operatorInput = $this->createElement('select', $identifier . '-operator', [ + 'options' => [ + '=' => '=', + '!=' => '!=', + '>' => '>', + '<' => '<', + '>=' => '>=', + '<=' => '<=' + ], + 'value' => QueryString::getRuleSymbol($condition) + ]); + + $valueInput = $this->createElement('text', $identifier . '-value', [ + 'value' => $condition->getValue(), + 'autocomplete' => 'off', + 'data-type' => 'value', + 'data-enrichment-type' => 'completion', + 'data-term-suggestions' => '#search-editor-suggestions' + ]); + $valueInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () { + return (string) $this->getSuggestionUrl(); + }); + + $this->registerElement($columnInput); + $this->registerElement($columnSearchInput); + $this->registerElement($operatorInput); + $this->registerElement($valueInput); + + return new HtmlElement( + 'fieldset', + Attributes::create(['name' => $identifier . '-']), + $columnInput, + $columnFakeInput, + $columnSearchInput, + $operatorInput, + $valueInput + ); + } + + protected function assemble() + { + $filterInput = $this->createElement('hidden', 'filter'); + $filterInput->getAttributes()->registerAttributeCallback( + 'value', + function () { + return $this->queryString ?: static::FAKE_COLUMN; + }, + [$this, 'setQueryString'] + ); + $this->addElement($filterInput); + + $filter = $this->getFilter(); + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $filter = Filter::equal('', ''); + } + + $this->addHtml($this->createTree($filter)); + $this->addHtml(new HtmlElement('div', Attributes::create([ + 'id' => 'search-editor-suggestions', + 'class' => 'search-suggestions' + ]))); + + if ($this->queryString) { + $this->addHtml($this->createElement('submitButton', 'structural-change', [ + 'value' => 'clear:rule-0', + 'class' => 'cancel-button', + 'label' => t('Clear Filter'), + 'formnovalidate' => true + ])); + } + + $this->addElement('submit', 'btn_submit', [ + 'label' => t('Apply') + ]); + + // Add submit button also as first element to make Web 2 submit + // the form instead of using a structural change to submit if + // the user just presses Enter. + $this->prepend($this->getElement('btn_submit')); + } + + private function popKey(array &$from, $key, $default = null) + { + if (isset($from[$key])) { + $value = $from[$key]; + unset($from[$key]); + + return $value; + } + + return $default; + } +} diff --git a/vendor/ipl/web/src/Control/SortControl.php b/vendor/ipl/web/src/Control/SortControl.php new file mode 100644 index 0000000..19954b3 --- /dev/null +++ b/vendor/ipl/web/src/Control/SortControl.php @@ -0,0 +1,251 @@ +<?php + +namespace ipl\Web\Control; + +use ipl\Html\FormElement\ButtonElement; +use ipl\Orm\Common\SortUtil; +use ipl\Orm\Query; +use ipl\Stdlib\Str; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; +use ipl\Web\Widget\Icon; + +/** + * Allows to adjust the order of the items to display + */ +class SortControl extends CompatForm +{ + /** @var string Default sort param */ + const DEFAULT_SORT_PARAM = 'sort'; + + protected $defaultAttributes = ['class' => 'icinga-form inline sort-control']; + + /** @var string Name of the URL parameter which stores the sort column */ + protected $sortParam = self::DEFAULT_SORT_PARAM; + + /** @var Url Request URL */ + protected $url; + + /** @var array Possible sort columns as sort string-value pairs */ + private $columns; + + /** @var string Default sort string */ + private $default; + + protected $method = 'GET'; + + /** + * Create a new sort control + * + * @param Url $url Request URL + */ + public function __construct(Url $url) + { + $this->url = $url; + } + + /** + * Create a new sort control with the given options + * + * @param array<string,string> $options A sort spec to label map + * + * @return static + */ + public static function create(array $options) + { + $normalized = []; + foreach ($options as $spec => $label) { + $normalized[SortUtil::normalizeSortSpec($spec)] = $label; + } + + return (new static(Url::fromRequest())) + ->setColumns($normalized); + } + + /** + * Get the possible sort columns + * + * @return array Sort string-value pairs + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Set the possible sort columns + * + * @param array $columns Sort string-value pairs + * + * @return $this + */ + public function setColumns(array $columns) + { + // We're working with lowercase keys throughout the sort control + $this->columns = array_change_key_case($columns, CASE_LOWER); + + return $this; + } + + /** + * Get the default sort string + * + * @return string + */ + public function getDefault() + { + return $this->default; + } + + /** + * Set the default sort string + * + * @param array|string $default + * + * @return $this + */ + public function setDefault($default) + { + // We're working with lowercase keys throughout the sort control + $this->default = strtolower($default); + + return $this; + } + + /** + * Get the name of the URL parameter which stores the sort + * + * @return string + */ + public function getSortParam() + { + return $this->sortParam; + } + + /** + * Set the name of the URL parameter which stores the sort + * + * @param string $sortParam + * + * @return $this + */ + public function setSortParam($sortParam) + { + $this->sortParam = $sortParam; + + return $this; + } + + /** + * Get the sort string + * + * @return string|null + */ + public function getSort() + { + $sort = $this->url->getParam($this->getSortParam(), $this->getDefault()); + + if (! empty($sort)) { + $columns = $this->getColumns(); + + if (! isset($columns[$sort])) { + // Choose sort string based on the first closest match + foreach (array_keys($columns) as $key) { + if (Str::startsWith($key, $sort)) { + $sort = $key; + + break; + } + } + } + } + + return $sort; + } + + /** + * Sort the given query according to the request + * + * @param Query $query + * + * @return $this + */ + public function apply(Query $query) + { + $default = (array) $query->getModel()->getDefaultSort(); + if (! empty($default)) { + $this->setDefault(SortUtil::normalizeSortSpec($default)); + } + + $sort = $this->getSort(); + if (! empty($sort)) { + $query->orderBy(SortUtil::createOrderBy($sort)); + } + + return $this; + } + + protected function assemble() + { + $columns = $this->getColumns(); + $sort = $this->getSort(); + + if (empty($sort)) { + reset($columns); + $sort = key($columns); + } + + $sort = explode(',', $sort, 2); + list($column, $direction) = Str::symmetricSplit(array_shift($sort), ' ', 2); + + if (! $direction || strtolower($direction) === 'asc') { + $toggleIcon = 'sort-alpha-down'; + $toggleDirection = 'desc'; + } else { + $toggleIcon = 'sort-alpha-down-alt'; + $toggleDirection = 'asc'; + } + + if ($direction !== null) { + $value = implode(',', array_merge(["{$column} {$direction}"], $sort)); + if (! isset($columns[$value])) { + foreach ([$column, "{$column} {$toggleDirection}"] as $key) { + $key = implode(',', array_merge([$key], $sort)); + if (isset($columns[$key])) { + $columns[$value] = $columns[$key]; + unset($columns[$key]); + + break; + } + } + } + } else { + $value = implode(',', array_merge([$column], $sort)); + } + + if (! isset($columns[$value])) { + $columns[$value] = 'Custom'; + } + + $this->addElement('select', $this->getSortParam(), [ + 'class' => 'autosubmit', + 'label' => 'Sort By', + 'options' => $columns, + 'value' => $value + ]) + ->getElement($this->getSortParam()) + ->getWrapper() + ->getAttributes() + ->add('class', 'icinga-controls'); + + $toggleButton = new ButtonElement($this->getSortParam(), [ + 'class' => 'control-button spinner', + 'title' => t('Change sort direction'), + 'type' => 'submit', + 'value' => implode(',', array_merge(["{$column} {$toggleDirection}"], $sort)) + ]); + $toggleButton->add(new Icon($toggleIcon)); + + $this->addElement($toggleButton); + } +} diff --git a/vendor/ipl/web/src/Filter/ParseException.php b/vendor/ipl/web/src/Filter/ParseException.php new file mode 100644 index 0000000..bcafd09 --- /dev/null +++ b/vendor/ipl/web/src/Filter/ParseException.php @@ -0,0 +1,36 @@ +<?php + +namespace ipl\Web\Filter; + +use Exception; + +class ParseException extends Exception +{ + protected $char; + + protected $charPos; + + public function __construct($filter, $char, $charPos, $extra) + { + parent::__construct(sprintf( + 'Invalid filter "%s", unexpected %s at pos %d%s', + $filter, + $char, + $charPos, + $extra + )); + + $this->char = $char; + $this->charPos = $charPos; + } + + public function getChar() + { + return $this->char; + } + + public function getCharPos() + { + return $this->charPos; + } +} diff --git a/vendor/ipl/web/src/Filter/Parser.php b/vendor/ipl/web/src/Filter/Parser.php new file mode 100644 index 0000000..248b41c --- /dev/null +++ b/vendor/ipl/web/src/Filter/Parser.php @@ -0,0 +1,572 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Events; +use ipl\Stdlib\Filter; + +class Parser +{ + use Events; + + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = 'on_condition'; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = 'on_chain'; + + /** @var string */ + protected $string; + + /** @var int */ + protected $pos; + + /** @var int */ + protected $termIndex; + + /** @var int */ + protected $length; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new Parser + * + * @param string $queryString The string to parse + */ + public function __construct($queryString = null) + { + if ($queryString !== null) { + $this->setQueryString($queryString); + } + } + + /** + * Set the query string to parse + * + * @param string $queryString + * + * @return $this + */ + public function setQueryString($queryString) + { + $this->string = (string) $queryString; + $this->length = strlen($queryString); + + return $this; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Parse the string and derive a filter rule from it + * + * @return Filter\Rule + */ + public function parse() + { + if ($this->length === 0) { + return Filter::all(); + } + + $this->pos = 0; + $this->termIndex = 0; + + return $this->readFilters(); + } + + /** + * Read filters + * + * @param int $nestingLevel + * @param string $op + * @param array $filters + * @param bool $explicit + * + * @return Filter\Chain|Filter\Condition + * @throws ParseException + */ + protected function readFilters($nestingLevel = 0, $op = null, $filters = null, $explicit = true) + { + $filters = empty($filters) ? [] : $filters; + $isNone = false; + + while ($this->pos < $this->length) { + $filter = $this->readCondition(); + $next = $this->readChar(); + + if ($filter === false) { + if ($next === '!') { + $isNone = true; + $this->termIndex++; + continue; + } + + if ($op === null && ($this->strict || count($filters) > 0) && ($next === '&' || $next === '|')) { + $op = $next; + $this->termIndex++; + continue; + } + + if ($next === false) { + // Nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === '(') { + $this->termIndex++; + + $rule = $this->readFilters($nestingLevel + 1, $isNone ? '!' : null); + if ($this->strict || ! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + + $isNone = false; + continue; + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === '&') { + if (! empty($filters)) { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next, "$op level $nestingLevel"); + } else { + if ($isNone) { + $isNone = false; + if ($filter->getValue() === true) { + // $filter is a result of `!column` + $filter->setValue(false); + $filters[] = $filter; + + $this->emit(self::ON_CONDITION, [$filter]); + } else { + // $filter is a result of `!column=[value]` + $none = Filter::none($filter); + $filters[] = $none; + + $this->emit(self::ON_CONDITION, [$filter]); + $this->emit(self::ON_CHAIN, [$none]); + } + } else { + $filters[] = $filter; + $this->emit(self::ON_CONDITION, [$filter]); + } + + if ($next === false) { + // Got filter, nothing more to read + break; + } + + if ($next === ')') { + if ($nestingLevel > 0) { + if (! $explicit) { + // The current chain was not initiated by a `(`, + // so this `)` does not belong to it, but still ends it + $this->pos--; + } else { + $this->termIndex++; + $next = $this->nextChar(); + if ($next !== false && ! in_array($next, ['&', '|', ')'])) { + $this->pos++; + $this->parseError($next, 'Expected logical operator'); + } + } + + break; + } + + $this->parseError($next); + } + + if ($next === $op) { + $this->termIndex++; + continue; + } + + if (in_array($next, ['&', '|'])) { + $this->termIndex++; + + // It's a different logical operator, continue parsing based on its precedence + if ($op === null || $op === '&') { + if ($op === '&') { + if (count($filters) > 1) { + $all = Filter::all(...$filters); + $filters = [$all]; + + $this->emit(self::ON_CHAIN, [$all]); + } else { + $filters = [$filters[0]]; + } + } + + $op = $next; + } elseif ($op === '|' || ($op === '!' && $next === '&')) { + $rule = $this->readFilters( + $nestingLevel + 1, + $next, + [array_pop($filters)], + false + ); + if (! $rule instanceof Filter\Chain || ! $rule->isEmpty()) { + $filters[] = $rule; + } + } + + continue; + } + + $this->parseError($next); + } + } + + if ($nestingLevel === 0 && $this->pos < $this->length) { + $this->parseError($op, 'Did not read full filter'); + } + + switch ($op) { + case '&': + $chain = Filter::all(...$filters); + break; + case '|': + $chain = Filter::any(...$filters); + break; + case '!': + $chain = Filter::none(...$filters); + break; + case null: + if ((! $this->strict || $nestingLevel === 0) && ! empty($filters)) { + // There is only one filter expression, no chain + return $filters[0]; + } + + $chain = Filter::all(...$filters); + break; + default: + $this->parseError($op); + } + + $this->emit(self::ON_CHAIN, [$chain]); + + return $chain; + } + + /** + * Read the next condition + * + * @return false|Filter\Condition + * + * @throws ParseException + */ + protected function readCondition() + { + if ('' === ($column = $this->readColumn())) { + return false; + } + + $columnIndex = $this->termIndex++; + + foreach (['<', '>'] as $operator) { + if (($pos = strpos($column, $operator)) !== false) { + if ($this->nextChar() === '=') { + break; + } + + $operatorIndex = $this->termIndex++; + + $value = substr($column, $pos + 1); + $column = substr($column, 0, $pos); + + $valueIndex = null; + if (ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + } + + if (in_array($this->nextChar(), ['=', '>', '<', '!'], true)) { + $operator = $this->readChar(); + } else { + $operator = false; + } + + if ($operator === false) { + $condition = Filter::equal($column, true); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', null) + ->set('valueIndex', null); + + return $condition; + } + + $operatorIndex = $this->termIndex++; + + $toFloat = false; + if ($operator === '=') { + $last = substr($column, -1); + if ($last === '>' || $last === '<') { + $operator = $last . $operator; + $column = substr($column, 0, -1); + $toFloat = true; + } + } elseif (in_array($operator, ['>', '<', '!'], true)) { + $toFloat = $operator === '>' || $operator === '<'; + if ($this->nextChar() === '=') { + $operator .= $this->readChar(); + } + } + + $valueIndex = null; + $value = $this->readValue(); + if ($toFloat && ctype_digit($value)) { + $value = (float) $value; + $valueIndex = $this->termIndex++; + } elseif ($value) { + $valueIndex = $this->termIndex++; + } + + $condition = $this->createCondition($column, $operator, $value); + $condition->metaData() + ->set('columnIndex', $columnIndex) + ->set('operatorIndex', $operatorIndex) + ->set('valueIndex', $valueIndex); + + return $condition; + } + + /** + * Read the next column + * + * @return false|string false if there is none + */ + protected function readColumn() + { + $str = $this->readUntil('=', '(', ')', '&', '|', '>', '<', '!'); + + if ($str === false) { + return $str; + } + + return rawurldecode($str); + } + + /** + * Read the next value + * + * @return string|string[] + * + * @throws ParseException In case there's a missing `)` + */ + protected function readValue() + { + if ($this->nextChar() === '(') { + $this->readChar(); + $var = array_map('rawurldecode', preg_split('~\|~', $this->readUntil(')'))); + + if ($this->readChar() !== ')') { + $this->parseError(null, 'Expected ")"'); + } + } else { + $var = rawurldecode($this->readUntil(')', '&', '|', '>', '<')); + } + + return $var; + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + /** + * Look at the next character + * + * @return false|string false if there is no character left + */ + protected function nextChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos]; + } + + return false; + } + + /** + * Create and return a condition + * + * @param string $column + * @param string $operator + * @param mixed $value + * + * @return Filter\Condition + */ + protected function createCondition($column, $operator, $value) + { + $column = trim($column); + + switch ($operator) { + case '=': + if (is_string($value) && strpos($value, "*") !== false) { + return Filter::like($column, $value); + } + + return Filter::equal($column, $value); + case '!=': + if (is_string($value) && strpos($value, '*') !== false) { + return Filter::unlike($column, $value); + } + + return Filter::unequal($column, $value); + case '>': + return Filter::greaterThan($column, $value); + case '>=': + return Filter::greaterThanOrEqual($column, $value); + case '<': + return Filter::lessThan($column, $value); + case '<=': + return Filter::lessThanOrEqual($column, $value); + } + } + + /** + * Throw a parse exception + * + * @param string $char + * @param string $extraMsg + * + * @throws ParseException + */ + protected function parseError($char = null, $extraMsg = null) + { + if ($extraMsg === null) { + $extra = ''; + } else { + $extra = ': ' . $extraMsg; + } + + if ($char === null) { + if ($this->pos < $this->length) { + $char = $this->string[$this->pos]; + } else { + $char = $this->string[--$this->pos]; + } + } + + throw new ParseException( + $this->string, + $char, + $this->pos, + $extra + ); + } +} diff --git a/vendor/ipl/web/src/Filter/QueryString.php b/vendor/ipl/web/src/Filter/QueryString.php new file mode 100644 index 0000000..235bf38 --- /dev/null +++ b/vendor/ipl/web/src/Filter/QueryString.php @@ -0,0 +1,92 @@ +<?php + +namespace ipl\Web\Filter; + +use InvalidArgumentException; +use ipl\Stdlib\Filter; + +final class QueryString +{ + /** @var string Emitted for every completely parsed condition */ + const ON_CONDITION = Parser::ON_CONDITION; + + /** @var string Emitted for every completely parsed chain */ + const ON_CHAIN = Parser::ON_CHAIN; + + /** + * This class is only a factory / helper + */ + private function __construct() + { + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Parser + */ + public static function fromString($string) + { + return new Parser($string); + } + + /** + * Derive a rule from the given query string + * + * @param string $string + * + * @return Filter\Rule + */ + public static function parse($string) + { + return (new Parser($string))->parse(); + } + + /** + * Assemble a query string for the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function render(Filter\Rule $rule) + { + return (new Renderer($rule))->render(); + } + + /** + * Get the symbol associated with the given rule + * + * @param Filter\Rule $rule + * + * @return string + */ + public static function getRuleSymbol(Filter\Rule $rule) + { + switch (true) { + case $rule instanceof Filter\Unequal: + case $rule instanceof Filter\Unlike: + return '!='; + case $rule instanceof Filter\Equal: + case $rule instanceof Filter\Like: + return '='; + case $rule instanceof Filter\GreaterThan: + return '>'; + case $rule instanceof Filter\LessThan: + return '<'; + case $rule instanceof Filter\GreaterThanOrEqual: + return '>='; + case $rule instanceof Filter\LessThanOrEqual: + return '<='; + case $rule instanceof Filter\All: + return '&'; + case $rule instanceof Filter\Any: + case $rule instanceof Filter\None: + return '|'; + default: + throw new InvalidArgumentException('Unknown rule type provided'); + } + } +} diff --git a/vendor/ipl/web/src/Filter/Renderer.php b/vendor/ipl/web/src/Filter/Renderer.php new file mode 100644 index 0000000..f01ccf4 --- /dev/null +++ b/vendor/ipl/web/src/Filter/Renderer.php @@ -0,0 +1,182 @@ +<?php + +namespace ipl\Web\Filter; + +use ipl\Stdlib\Filter; + +class Renderer +{ + /** @var Filter\Rule */ + protected $filter; + + /** @var string */ + protected $string; + + /** @var bool Whether strict mode is enabled */ + protected $strict = false; + + /** + * Create a new filter Renderer + * + * @param Filter\Rule $filter + */ + public function __construct(Filter\Rule $filter) + { + $this->filter = $filter; + } + + /** + * Set whether strict mode is enabled + * + * @param bool $strict + * + * @return $this + */ + public function setStrict($strict = true) + { + $this->strict = (bool) $strict; + + return $this; + } + + /** + * Assemble and return the filter as query string + * + * @return string + */ + public function render() + { + if ($this->string !== null) { + return $this->string; + } + + $this->string = ''; + $filter = $this->filter; + + if ($filter instanceof Filter\Chain) { + $this->renderChain($filter, $this->strict); + } else { + /** @var Filter\Condition $filter */ + $this->renderCondition($filter); + } + + return $this->string; + } + + /** + * Assemble the given filter Chain + * + * @param Filter\Chain $chain + * @param bool $wrap + * + * @return void + */ + protected function renderChain(Filter\Chain $chain, $wrap = false) + { + if (! $this->strict && $chain->isEmpty()) { + return; + } + + $chainOperator = null; + switch (true) { + case $chain instanceof Filter\All: + $chainOperator = '&'; + break; + case $chain instanceof Filter\None: + $this->string .= '!'; + + // Force wrap, it may be the root node + if (! $wrap) { + if ($chain->count() > 1) { + $wrap = true; + } else { + $iterator = $chain->getIterator(); + $wrap = $iterator->current() instanceof Filter\None; + } + } + + // None shares the operator with Any + case $chain instanceof Filter\Any: + $chainOperator = '|'; + break; + } + + if ($wrap) { + $this->string .= '('; + } + + foreach ($chain as $rule) { + if ($rule instanceof Filter\Chain) { + $this->renderChain($rule, $this->strict || $rule->count() > 1); + } else { + /** @var Filter\Condition $rule */ + $this->renderCondition($rule); + } + + $this->string .= $chainOperator; + } + + if (! $chain->isEmpty() && (! $this->strict || ! ($chain instanceof Filter\Any && $chain->count() === 1))) { + // Remove redundant chain operator added last + $this->string = substr($this->string, 0, -1); + } elseif ($chain->isEmpty() && $chain instanceof Filter\Any) { + // If the chain is empty and strict mode is on, we need a + // chain operator to designate it's an OR, not an AND + $this->string .= $chainOperator; + } + + if ($wrap) { + $this->string .= ')'; + } + } + + /** + * Assemble the given filter Condition + * + * @param Filter\Condition $condition + * + * @return void + */ + protected function renderCondition(Filter\Condition $condition) + { + $value = $condition->getValue(); + if (is_bool($value) && ! $value) { + $this->string .= '!'; + } + + $this->string .= rawurlencode($condition->getColumn()); + + if (is_bool($value)) { + return; + } + + switch (true) { + case $condition instanceof Filter\Unequal: + case $condition instanceof Filter\Unlike: + $this->string .= '!='; + break; + case $condition instanceof Filter\Equal: + case $condition instanceof Filter\Like: + $this->string .= '='; + break; + case $condition instanceof Filter\GreaterThan: + $this->string .= rawurlencode('>'); + break; + case $condition instanceof Filter\LessThan: + $this->string .= rawurlencode('<'); + break; + case $condition instanceof Filter\GreaterThanOrEqual: + $this->string .= rawurlencode('>') . '='; + break; + case $condition instanceof Filter\LessThanOrEqual: + $this->string .= rawurlencode('<') . '='; + break; + } + + if (is_array($value)) { + $this->string .= '(' . join('|', array_map('rawurlencode', $value)) . ')'; + } elseif ($value !== null) { + $this->string .= rawurlencode($value); + } + } +} diff --git a/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php new file mode 100644 index 0000000..f936338 --- /dev/null +++ b/vendor/ipl/web/src/FormDecorator/IcingaFormDecorator.php @@ -0,0 +1,96 @@ +<?php + +namespace ipl\Web\FormDecorator; + +use Icinga\Web\Window; +use ipl\Html\Attributes; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormDecorator\DivDecorator; +use ipl\Html\FormElement\CheckboxElement; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Widget\Icon; + +class IcingaFormDecorator extends DivDecorator +{ + const SUBMIT_ELEMENT_CLASS = 'form-controls'; + const INPUT_ELEMENT_CLASS = 'control-group'; + const ERROR_CLASS = 'errors'; + + protected function assembleElement() + { + if ($this->formElement instanceof FormSubmitElement) { + $this->formElement->getAttributes()->add('class', 'btn-primary'); + } + + $element = parent::assembleElement(); + + if ($element instanceof CheckboxElement) { + return $this->createCheckbox($element); + } + + return $element; + } + + protected function createCheckbox(CheckboxElement $checkbox) + { + if (! $checkbox->getAttributes()->has('id')) { + $checkbox->setAttribute('id', Window::generateId()); + } + + $checkbox->getAttributes()->add('class', 'sr-only'); + + $classes = ['toggle-switch']; + if ($checkbox->getAttributes()->get('disabled')->getValue()) { + $classes[] = 'disabled'; + } + + return [ + $checkbox, + new HtmlElement('label', Attributes::create([ + 'class' => $classes, + 'aria-hidden' => 'true', + 'for' => $checkbox->getAttributes()->get('id')->getValue() + ]), new HtmlElement('span', Attributes::create(['class' => 'toggle-slider']))) + ]; + } + + protected function assembleLabel() + { + $label = parent::assembleLabel(); + if ($label !== null) { + $label->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'control-label-group']))); + } + + return $label; + } + + protected function assembleDescription() + { + if (($description = $this->formElement->getDescription()) !== null) { + $iconAttributes = [ + 'class' => 'control-info', + 'role' => 'image', + 'title' => $description + ]; + + $describedBy = null; + if ($this->formElement->getAttributes()->has('id')) { + $iconAttributes['aria-hidden'] = 'true'; + + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $describedBy = new HtmlElement('span', Attributes::create([ + 'id' => $descriptionId, + 'class' => 'sr-only' + ]), Text::create($description)); + + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return [ + new Icon('info-circle', $iconAttributes), + $describedBy + ]; + } + } +} diff --git a/vendor/ipl/web/src/Layout/Content.php b/vendor/ipl/web/src/Layout/Content.php new file mode 100644 index 0000000..bded4ab --- /dev/null +++ b/vendor/ipl/web/src/Layout/Content.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for content + */ +class Content extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'content']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/Layout/Controls.php b/vendor/ipl/web/src/Layout/Controls.php new file mode 100644 index 0000000..8763775 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Controls.php @@ -0,0 +1,59 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; +use ipl\Web\Widget\Tabs; + +/** + * Container for controls + */ +class Controls extends BaseHtmlElement +{ + /** @var Tabs */ + protected $tabs; + + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'controls']; + + protected $tag = 'div'; + + /** + * Get the tabs + * + * @return Tabs + */ + public function getTabs() + { + return $this->tabs; + } + + /** + * Set the tabs + * + * @param Tabs $tabs + * + * @return $this + */ + public function setTabs(Tabs $tabs) + { + $this->tabs = $tabs; + + return $this; + } + + public function isEmpty() + { + if (! parent::isEmpty()) { + return false; + } + + return $this->tabs->count() === 0; + } + + protected function assemble() + { + $this->prepend($this->getTabs()); + } +} diff --git a/vendor/ipl/web/src/Layout/Footer.php b/vendor/ipl/web/src/Layout/Footer.php new file mode 100644 index 0000000..21bf262 --- /dev/null +++ b/vendor/ipl/web/src/Layout/Footer.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Web\Layout; + +use ipl\Html\BaseHtmlElement; + +/** + * Container for footer + */ +class Footer extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + protected $defaultAttributes = ['class' => 'footer']; + + protected $tag = 'div'; +} diff --git a/vendor/ipl/web/src/Url.php b/vendor/ipl/web/src/Url.php new file mode 100644 index 0000000..52620c4 --- /dev/null +++ b/vendor/ipl/web/src/Url.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web; + +/** + * @TODO(el): Don't depend on Icinga Web's Url + */ +class Url extends \Icinga\Web\Url +{ + public function __toString() + { + return $this->getAbsoluteUrl('&'); + } +} diff --git a/vendor/ipl/web/src/Widget/ActionBar.php b/vendor/ipl/web/src/Widget/ActionBar.php new file mode 100644 index 0000000..bf31845 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionBar.php @@ -0,0 +1,51 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Action bar element for displaying a list of links + */ +class ActionBar extends BaseHtmlElement +{ + use BaseTarget; + + protected $contentSeparator = ' '; + + protected $defaultAttributes = [ + 'class' => 'action-bar', + 'data-base-target' => '_self' + ]; + + protected $tag = 'div'; + + /** + * Create a action bar + * + * @param Attributes|array $attributes + */ + public function __construct($attributes = null) + { + $this->getAttributes()->add($attributes); + } + + /** + * Add a link to the action bar + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->add(new ActionLink($content, $url, $icon)); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/ActionLink.php b/vendor/ipl/web/src/Widget/ActionLink.php new file mode 100644 index 0000000..289d700 --- /dev/null +++ b/vendor/ipl/web/src/Widget/ActionLink.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Web\Url; + +/** + * Link generally pointing to CRUD actions + */ +class ActionLink extends Link +{ + protected $defaultAttributes = ['class' => 'action-link']; + + /** + * Create a action link + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $icon = null, $attributes = null) + { + parent::__construct($content, $url, $attributes); + + if ($icon !== null) { + $this->prepend(new Icon($icon)); + } + } +} diff --git a/vendor/ipl/web/src/Widget/ButtonLink.php b/vendor/ipl/web/src/Widget/ButtonLink.php new file mode 100644 index 0000000..2da5dfd --- /dev/null +++ b/vendor/ipl/web/src/Widget/ButtonLink.php @@ -0,0 +1,14 @@ +<?php + +namespace ipl\Web\Widget; + +/** + * Button like link generally pointing to CRUD actions + */ +class ButtonLink extends ActionLink +{ + protected $defaultAttributes = [ + 'class' => 'button-link', + 'data-base-target' => '_main' + ]; +} diff --git a/vendor/ipl/web/src/Widget/ContinueWith.php b/vendor/ipl/web/src/Widget/ContinueWith.php new file mode 100644 index 0000000..4aaf52e --- /dev/null +++ b/vendor/ipl/web/src/Widget/ContinueWith.php @@ -0,0 +1,72 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Stdlib\Filter; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Filter\QueryString; +use ipl\Web\Url; + +class ContinueWith extends BaseHtmlElement +{ + use BaseTarget; + + protected $tag = 'span'; + + protected $defaultAttributes = ['class' => 'continue-with']; + + /** @var Url */ + protected $url; + + /** @var Filter\Rule|callable */ + protected $filter; + + /** @var string */ + protected $title; + + public function __construct(Url $url, $filter) + { + $this->url = $url; + $this->filter = $filter; + } + + /** + * Set title for the anchor + * + * @param string $title + * + * @return $this + */ + public function setTitle($title) + { + $this->title = $title; + + return $this; + } + + public function assemble() + { + $filter = $this->filter; + if (is_callable($filter)) { + $filter = $filter(); /** @var Filter\Rule $filter */ + } + + if ($filter instanceof Filter\Chain && $filter->isEmpty()) { + $this->addHtml(new HtmlElement( + 'span', + Attributes::create(['class' => ['control-button', 'disabled']]), + new Icon('share') + )); + } else { + $this->addHtml(new ActionLink( + null, + $this->url->setQueryString(QueryString::render($filter)), + 'share', + ['class' => 'control-button', 'title' => $this->title] + )); + } + } +} diff --git a/vendor/ipl/web/src/Widget/Dropdown.php b/vendor/ipl/web/src/Widget/Dropdown.php new file mode 100644 index 0000000..b6eb20d --- /dev/null +++ b/vendor/ipl/web/src/Widget/Dropdown.php @@ -0,0 +1,63 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use ipl\Web\Url; + +/** + * Toggleable overlay dropdown element for displaying a list of links + */ +class Dropdown extends BaseHtmlElement +{ + /** @var array */ + protected $links = []; + + protected $defaultAttributes = ['class' => 'dropdown']; + + protected $tag = 'div'; + + /** + * Create a dropdown element + * + * @param mixed $content + * @param Attributes|array $attributes + */ + public function __construct($content, $attributes = null) + { + $toggle = new ActionLink($content, '#', null, [ + 'aria-expanded' => false, + 'aria-haspopup' => true, + 'class' => 'dropdown-toggle', + 'role' => 'button' + ]); + + $this + ->setContent($toggle) + ->getAttributes() + ->add($attributes); + } + + /** + * Add a link to the dropdown + * + * @param mixed $content + * @param Url|string $url + * @param string $icon + * + * @return $this + */ + public function addLink($content, $url, $icon = null) + { + $this->links[] = new ActionLink($content, $url, $icon, ['class' => 'dropdown-item']); + + return $this; + } + + protected function assemble() + { + $this->add(Html::tag('div', ['class' => 'dropdown-menu'], $this->links)); + } +} diff --git a/vendor/ipl/web/src/Widget/HorizontalKeyValue.php b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php new file mode 100644 index 0000000..1d1195e --- /dev/null +++ b/vendor/ipl/web/src/Widget/HorizontalKeyValue.php @@ -0,0 +1,31 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class HorizontalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $defaultAttributes = ['class' => 'horizontal-key-value']; + + protected $tag = 'div'; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('div', ['class' => 'key'], $this->key), + Html::tag('div', ['class' => 'value'], $this->value) + ]); + } +} diff --git a/vendor/ipl/web/src/Widget/IcingaIcon.php b/vendor/ipl/web/src/Widget/IcingaIcon.php new file mode 100644 index 0000000..8002dcc --- /dev/null +++ b/vendor/ipl/web/src/Widget/IcingaIcon.php @@ -0,0 +1,26 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; + +class IcingaIcon extends Icon +{ + /** + * Create a icon element + * + * Creates a icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'iicon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct($name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', "iicon-$name"]) + ->add($attributes); + } +} diff --git a/vendor/ipl/web/src/Widget/Icon.php b/vendor/ipl/web/src/Widget/Icon.php new file mode 100644 index 0000000..2198c87 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Icon.php @@ -0,0 +1,32 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; + +/** + * Icon element + */ +class Icon extends BaseHtmlElement +{ + protected $tag = 'i'; + + /** + * Create a icon element + * + * Creates a icon element from the given name and HTML attributes. The icon element's tag will be <i>. The given + * name will be used as automatically added CSS class for the icon element in the format 'icon-$name'. In addition, + * the CSS class 'icon' will be automatically added too. + * + * @param string $name The name of the icon + * @param Attributes|array $attributes The HTML attributes for the element + */ + public function __construct($name, $attributes = null) + { + $this + ->getAttributes() + ->add('class', ['icon', 'fa', "fa-$name"]) + ->add($attributes); + } +} diff --git a/vendor/ipl/web/src/Widget/Link.php b/vendor/ipl/web/src/Widget/Link.php new file mode 100644 index 0000000..2d7c9f5 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Link.php @@ -0,0 +1,83 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Web\Common\BaseTarget; +use ipl\Web\Url; + +/** + * Link element, i.e. <a href="... + */ +class Link extends BaseHtmlElement +{ + use BaseTarget; + + /** @var Url */ + protected $url; + + protected $tag = 'a'; + + /** + * Create a link element + * + * @param mixed $content + * @param Url|string $url + * @param Attributes|array $attributes + */ + public function __construct($content, $url, $attributes = null) + { + $this + ->setContent($content) + ->setUrl($url) + ->getAttributes() + ->add($attributes) + ->registerAttributeCallback('href', [$this, 'createHrefAttribute']); + } + + /** + * Get the URL of the link + * + * @return Url + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the URL of the link + * + * @param Url|string $url + * + * @return $this + */ + public function setUrl($url) + { + if (! $url instanceof Url) { + try { + $url = Url::fromPath($url); + } catch (\Exception $e) { + $url = 'invalid'; + } + } + + $this->url = $url; + + return $this; + } + + /** + * Create and return the href attribute + * + * Used as attribute callback for the href attribute. + * + * @return Attribute + */ + public function createHrefAttribute() + { + return new Attribute('href', (string) $this->getUrl()); + } +} diff --git a/vendor/ipl/web/src/Widget/StateBadge.php b/vendor/ipl/web/src/Widget/StateBadge.php new file mode 100644 index 0000000..908a348 --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBadge.php @@ -0,0 +1,47 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +class StateBadge extends BaseHtmlElement +{ + protected $defaultAttributes = ['class' => 'state-badge']; + + /** @var mixed Badge content */ + protected $content; + + /** @var bool Whether the state is handled */ + protected $isHandled; + + /** @var string Textual representation of a state */ + protected $state; + + /** + * Create a new state badge + * + * @param mixed $content Content of the badge + * @param string $state Textual representation of a state + * @param bool $isHandled True if state is handled + */ + public function __construct($content, string $state, bool $isHandled = false) + { + $this->content = $content; + $this->isHandled = $isHandled; + $this->state = $state; + } + + protected function assemble() + { + $this->setTag('span'); + + $class = "state-{$this->state}"; + if ($this->isHandled) { + $class .= ' handled'; + } + + $this->addAttributes(['class' => $class]); + + $this->add($this->content); + } +} diff --git a/vendor/ipl/web/src/Widget/StateBall.php b/vendor/ipl/web/src/Widget/StateBall.php new file mode 100644 index 0000000..5a1216d --- /dev/null +++ b/vendor/ipl/web/src/Widget/StateBall.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; + +/** + * State ball element that supports different sizes and colors + */ +class StateBall extends BaseHtmlElement +{ + const SIZE_TINY = 'xs'; + const SIZE_SMALL = 's'; + const SIZE_MEDIUM = 'm'; + const SIZE_MEDIUM_LARGE = 'ml'; + const SIZE_BIG = 'l'; + const SIZE_LARGE = 'xl'; + + protected $tag = 'span'; + + /** + * Create a new state ball element + * + * @param string $state + * @param string $size + */ + public function __construct($state = 'none', $size = self::SIZE_SMALL) + { + $state = trim($state); + + if (empty($state)) { + $state = 'none'; + } + + $size = trim($size); + + if (empty($size)) { + $size = self::SIZE_MEDIUM; + } + + $this->defaultAttributes = ['class' => "state-ball state-$state ball-size-$size"]; + } +} diff --git a/vendor/ipl/web/src/Widget/Tabs.php b/vendor/ipl/web/src/Widget/Tabs.php new file mode 100644 index 0000000..32ba8e9 --- /dev/null +++ b/vendor/ipl/web/src/Widget/Tabs.php @@ -0,0 +1,190 @@ +<?php + +namespace ipl\Web\Widget; + +use Exception; +use Icinga\Web\Widget\Tabextension\DashboardAction; +use Icinga\Web\Widget\Tabextension\MenuAction; +use Icinga\Web\Widget\Tabextension\OutputFormat; +use Icinga\Web\Widget\Tabextension\Tabextension; +use InvalidArgumentException; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlString; +use ipl\Web\Url; + +/** + * @TODO(el): Don't depend on Icinga Web's Tabs + */ +class Tabs extends BaseHtmlElement +{ + protected $tag = 'ul'; + + protected $defaultAttributes = ['class' => 'tabs primary-nav nav']; + + /** @var \Icinga\Web\Widget\Tabs */ + protected $tabs; + + /** @var bool Whether data exports are enabled */ + protected $dataExportsEnabled = false; + + /** @var bool Whether the legacy extensions should be shown by default */ + protected $legacyExtensionsEnabled = true; + + /** @var Url */ + protected $refreshUrl; + + public function __construct() + { + $this->tabs = new \Icinga\Web\Widget\Tabs(); + } + + /** + * Don't show legacy extensions by default + */ + public function disableLegacyExtensions() + { + $this->legacyExtensionsEnabled = false; + } + + /** + * Show export actions for JSON and CSV + */ + public function enableDataExports() + { + $this->dataExportsEnabled = true; + } + + /** + * Set the url for the refresh button + * + * @param Url $url + * + * @return $this + */ + public function setRefreshUrl(Url $url) + { + $this->refreshUrl = $url; + + return $this; + } + + protected function assemble() + { + if ($this->legacyExtensionsEnabled) { + $this->tabs->extend(new OutputFormat( + $this->dataExportsEnabled + ? [] + : [OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON] + )) + ->extend(new DashboardAction()) + ->extend(new MenuAction()); + } + + $tabHtml = substr($this->tabs->render(), 34, -5); + if ($this->refreshUrl !== null) { + $tabHtml = preg_replace( + '/(?<=class="refresh-container-control spinner" href=")([^"]*)/', + $this->refreshUrl->getAbsoluteUrl(), + $tabHtml + ); + } + + parent::add(HtmlString::create($tabHtml)); + } + + /** + * Activate the tab with the given name + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function activate($name) + { + try { + $this->tabs->activate($name); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + return $this; + } + + /** + * Get active tab + * + * @return \Icinga\Web\Widget\Tab + */ + public function getActiveTab() + { + return $this->tabs->get($this->tabs->getActiveName()); + } + + /** + * Add the given tab + * + * @param string $name + * @param mixed $tab + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function add($name, $tab = null) + { + if ($tab === null) { + throw new InvalidArgumentException('Argument $tab is required'); + } + + try { + $this->tabs->add($name, $tab); + } catch (Exception $e) { + throw new InvalidArgumentException($e->getMessage()); + } + + if (is_array($tab) && isset($tab['active']) && $tab['active']) { + // Otherwise Tabs::getActiveName() returns null + $this->tabs->activate($name); + } + + return $this; + } + + /** + * Get a tab + * + * @param string $name + * + * @return \Icinga\Web\Widget\Tab|null + */ + public function get($name) + { + return $this->tabs->get($name); + } + + /** + * Count tabs + * + * @return int + */ + public function count(): int + { + return $this->tabs->count(); + } + + /** + * Apply a Tabextension on $this->tabs object not on this class + * + * @param Tabextension $extension + * + * @return $this + */ + public function extend(Tabextension $extension) + { + $this->tabs->extend($extension); + + return $this; + } +} diff --git a/vendor/ipl/web/src/Widget/TimeAgo.php b/vendor/ipl/web/src/Widget/TimeAgo.php new file mode 100644 index 0000000..cbd0dad --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeAgo.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeAgo extends BaseHtmlElement +{ + /** @var int */ + protected $ago; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-ago']; + + public function __construct($ago) + { + $this->ago = (int) $ago; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->ago); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeAgo($this->ago)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeSince.php b/vendor/ipl/web/src/Widget/TimeSince.php new file mode 100644 index 0000000..308e358 --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeSince.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeSince extends BaseHtmlElement +{ + /** @var int */ + protected $since; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-since']; + + public function __construct($since) + { + $this->since = (int) $since; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->since); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeSince($this->since)); + } +} diff --git a/vendor/ipl/web/src/Widget/TimeUntil.php b/vendor/ipl/web/src/Widget/TimeUntil.php new file mode 100644 index 0000000..ba1f527 --- /dev/null +++ b/vendor/ipl/web/src/Widget/TimeUntil.php @@ -0,0 +1,33 @@ +<?php + +namespace ipl\Web\Widget; + +use Icinga\Date\DateFormatter; +use ipl\Html\BaseHtmlElement; + +class TimeUntil extends BaseHtmlElement +{ + /** @var int */ + protected $until; + + protected $tag = 'time'; + + protected $defaultAttributes = ['class' => 'time-until']; + + public function __construct($until) + { + $this->until = (int) $until; + } + + protected function assemble() + { + $dateTime = DateFormatter::formatDateTime($this->until); + + $this->addAttributes([ + 'datetime' => $dateTime, + 'title' => $dateTime + ]); + + $this->add(DateFormatter::timeUntil($this->until)); + } +} diff --git a/vendor/ipl/web/src/Widget/VerticalKeyValue.php b/vendor/ipl/web/src/Widget/VerticalKeyValue.php new file mode 100644 index 0000000..388c740 --- /dev/null +++ b/vendor/ipl/web/src/Widget/VerticalKeyValue.php @@ -0,0 +1,32 @@ +<?php + +namespace ipl\Web\Widget; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; + +class VerticalKeyValue extends BaseHtmlElement +{ + protected $key; + + protected $value; + + protected $tag = 'div'; + + protected $defaultAttributes = ['class' => 'vertical-key-value']; + + public function __construct($key, $value) + { + $this->key = $key; + $this->value = $value; + } + + protected function assemble() + { + $this->add([ + Html::tag('span', ['class' => 'value'], $this->value), + Html::tag('br'), + Html::tag('span', ['class' => 'key'], $this->key), + ]); + } +} |