src/Form/Control/Dropdown.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Ui\Form\Control;

use Atk4\Data\Model;
use Atk4\Ui\HtmlTemplate;
use Atk4\Ui\Js\Jquery;
use Atk4\Ui\Js\JsExpression;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\Js\JsFunction;

class Dropdown extends Input
{
    public $defaultTemplate = 'form/control/dropdown.html';

    public string $inputType = 'hidden';

    /**
     * Values need for the dropdown.
     * Note: Now possible to display icon with value in dropdown by passing the icon class with your values.
     * ex: 'values' => [
     *     'tag' => ['Tag', 'icon' => 'tag'],
     *     'globe' => ['Globe', 'icon' => 'globe'],
     *     'registered' => ['Registered', 'icon' => 'registered'],
     *     'file' => ['File', 'icon' => 'file'],
     * ].
     *
     * @var array<array-key, mixed>
     */
    public array $values;

    /** @var string The string to set as an empty values. */
    public $empty = "\u{00a0}"; // Unicode NBSP

    /** @var array<string, mixed> Dropdown options as per Fomantic-UI dropdown options. */
    public $dropdownOptions = [];

    /**
     * Whether or not to accept multiple value.
     *   Multiple values are sent using a string with comma as value delimiter.
     *   ex: 'value1,value2,value3'.
     *
     * @var bool
     */
    public $multiple = false;

    /**
     * Here a custom function for creating the HTML of each dropdown option
     * can be defined. The function gets each row of the model/values property as first parameter.
     * if used with $values property, gets the key of this element as second parameter.
     * Must return an array with at least 'value' and 'title' elements set.
     * Use additional 'icon' element to add an icon to this row.
     *
     * Example 1 with Model: Title in Uppercase
     * function (Model $row) {
     *     return [
     *         'title' => mb_strtoupper($row->getTitle()),
     *     ];
     *  }
     *
     * Example 2 with Model: Add an icon
     * function (Model $row) {
     *     return [
     *         'title' => $row->getTitle(),
     *         'icon' => $row->get('amount') > 1000 ? 'money' : '',
     *     ];
     * }
     *
     * Example 3 with Model: Combine Title from model fields
     * function (Model $row) {
     *     return [
     *         'title' => $row->getTitle() . ' (' . $row->get('title2') . ')',
     *     ];
     * }
     *
     * Example 4 with $values property Array:
     * function (string $value, $key) {
     *     return [
     *        'value' => $key,
     *        'title' => mb_strtoupper($value),
     *        'icon' => str_contains($value, 'Month') ? 'calendar' : '',
     *     ];
     * }
     *
     * @var \Closure<T of Model>(T): array{title: mixed, icon?: mixed}|\Closure(mixed, array-key): array{value: mixed, title: mixed, icon?: mixed}
     */
    public ?\Closure $renderRowFunction = null;

    /** Subtemplate for a single dropdown item. */
    protected HtmlTemplate $_tItem;

    /** Subtemplate for an icon for a single dropdown item. */
    protected HtmlTemplate $_tIcon;

    #[\Override]
    protected function init(): void
    {
        parent::init();

        $this->_tItem = $this->template->cloneRegion('Item');
        $this->template->del('Item');
        $this->_tIcon = $this->_tItem->cloneRegion('Icon');
        $this->_tItem->del('Icon');
    }

    #[\Override]
    public function getValue()
    {
        // dropdown input tag accepts CSV formatted list of IDs
        return $this->entityField !== null
            ? (is_array($this->entityField->get()) ? implode(', ', $this->entityField->get()) : $this->entityField->get()) // TODO is_array() should be replaced with field type condition
            : parent::getValue();
    }

    #[\Override]
    public function set($value = null)
    {
        if ($this->entityField !== null) {
            if ($this->entityField->getField()->type === 'json' && is_string($value)) {
                $value = explode(',', $value);
            }
        }

        return parent::set($value);
    }

    /**
     * Set JS dropdown() specific option;.
     *
     * @param string $option
     * @param mixed  $value
     */
    public function setDropdownOption($option, $value): void
    {
        $this->dropdownOptions[$option] = $value;
    }

    /**
     * Set JS dropdown() options.
     *
     * @param array<string, mixed> $options
     */
    public function setDropdownOptions($options): void
    {
        $this->dropdownOptions = array_merge($this->dropdownOptions, $options);
    }

    /**
     * @param bool|string      $when
     * @param JsExpressionable $action
     *
     * @return Jquery
     */
    protected function jsDropdown($when = false, $action = null): JsExpressionable
    {
        return $this->js($when, $action, 'div.ui.dropdown:has(> #' . $this->name . '_input)');
    }

    protected function jsRenderDropdown(): JsExpressionable
    {
        return $this->jsDropdown(true)->dropdown($this->dropdownOptions);
    }

    protected function htmlRenderValue(): void
    {
        // add selection only if no value is required and Dropdown has no multiple selections enabled
        if ($this->entityField !== null && !$this->entityField->getField()->required && !$this->multiple) {
            $this->_tItem->set('value', '');
            $this->_tItem->set('title', $this->empty);
            $this->template->dangerouslyAppendHtml('Item', $this->_tItem->renderToHtml());
        }

        // model set? use this, else values property
        if ($this->model !== null) {
            if ($this->renderRowFunction) {
                foreach ($this->model as $row) {
                    $this->_addCallBackRow($row);
                }
            } else {
                // for standard model rendering, only load ID and title field
                $this->model->setOnlyFields([$this->model->titleField, $this->model->idField]);
                $this->_renderItemsForModel();
            }
        } else {
            if ($this->renderRowFunction) {
                foreach ($this->values as $key => $value) {
                    $this->_addCallBackRow($value, $key);
                }
            } else {
                $this->_renderItemsForValues();
            }
        }
    }

    #[\Override]
    protected function renderView(): void
    {
        if ($this->multiple) {
            $this->template->dangerouslySetHtml('multipleClass', 'multiple');
        }

        if ($this->readOnly || $this->disabled) {
            if ($this->multiple) {
                $this->jsDropdown(true)->find('a i.delete.icon')->attr('class', 'disabled');
            }
        }

        if ($this->disabled) {
            $this->template->set('disabledClass', 'disabled');
            $this->template->dangerouslySetHtml('disabled', 'disabled="disabled"');
        } elseif ($this->readOnly) {
            $this->template->set('disabledClass', 'read-only');
            $this->template->dangerouslySetHtml('disabled', 'readonly="readonly"');

            $this->setDropdownOption('onShow', new JsFunction([], [new JsExpression('return false')]));
        }

        $this->template->set('DefaultText', $this->empty);

        $this->htmlRenderValue();
        $this->jsRenderDropdown();

        parent::renderView();
    }

    /**
     * Sets the dropdown items to the template if a model is used.
     */
    protected function _renderItemsForModel(): void
    {
        foreach ($this->model as $id => $row) {
            $title = $row->getTitle();
            $this->_tItem->set('value', $this->getApp()->uiPersistence->typecastAttributeSaveField($this->model->getIdField(), $id));
            $this->_tItem->set('title', $title || is_numeric($title) ? (string) $title : '');
            // add item to template
            $this->template->dangerouslyAppendHtml('Item', $this->_tItem->renderToHtml());
        }
    }

    /**
     * Sets the dropdown items from $this->values array.
     */
    protected function _renderItemsForValues(): void
    {
        foreach ($this->values as $key => $val) {
            $this->_tItem->set('value', (string) $key);
            if (is_array($val)) {
                if (array_key_exists('icon', $val)) {
                    $this->_tIcon->set('iconClass', $val['icon'] . ' icon');
                    $this->_tItem->dangerouslySetHtml('Icon', $this->_tIcon->renderToHtml());
                } else {
                    $this->_tItem->del('Icon');
                }
                $this->_tItem->set('title', $val[0] || is_numeric($val[0]) ? (string) $val[0] : '');
            } else {
                $this->_tItem->set('title', $val || is_numeric($val) ? (string) $val : '');
            }

            // add item to template
            $this->template->dangerouslyAppendHtml('Item', $this->_tItem->renderToHtml());
        }
    }

    /**
     * Used when a custom callback is defined for row rendering. Sets
     * values to row template and appends it to main template.
     *
     * @param mixed                               $row
     * @param ($row is Model ? never : array-key) $key
     */
    protected function _addCallBackRow($row, $key = null): void
    {
        if ($this->model !== null) {
            $res = ($this->renderRowFunction)($row);
            $this->_tItem->set('value', $this->getApp()->uiPersistence->typecastAttributeSaveField($this->model->getIdField(), $row->getId()));
        } else {
            $res = ($this->renderRowFunction)($row, $key);
            $this->_tItem->set('value', (string) $res['value']);
        }

        $this->_tItem->set('title', $res['title']);

        $this->_tItem->del('Icon');
        if (isset($res['icon']) && $res['icon']) {
            // compatibility with how $values property works on icons: 'icon'
            // is defined in there
            $this->_tIcon->set('iconClass', 'icon ' . $res['icon']);
            $this->_tItem->dangerouslyAppendHtml('Icon', $this->_tIcon->renderToHtml());
        }

        // add item to template
        $this->template->dangerouslyAppendHtml('Item', $this->_tItem->renderToHtml());
    }
}