src/Form/Control/Upload.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

namespace Atk4\Ui\Form\Control;

use Atk4\Ui\Button;
use Atk4\Ui\Exception;
use Atk4\Ui\Js\JsBlock;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\JsCallback;

/**
 * @phpstan-type PhpFileArray array{error: int, name: string}
 */
class Upload extends Input
{
    public $defaultTemplate = 'form/control/upload.html';

    public string $inputType = 'hidden';

    /**
     * The uploaded file ID.
     * This ID is return on form submit.
     * If not set, will default to file name.
     * file ID is also sent with onDelete Callback.
     *
     * @var string|null
     */
    public $fileId;

    /** @var JsCallback Callback is use for onUpload or onDelete. */
    public $cb;

    /**
     * Allow multiple file or not.
     * CURRENTLY NOT SUPPORTED.
     *
     * @var bool
     */
    public $multiple = false;

    /**
     * An array of string value for accept file type.
     * ex: ['.jpg', '.jpeg', '.png'] or ['images/*'].
     *
     * @var list<string>
     */
    public $accept = [];

    /** Whether callback has been defined or not. */
    public bool $hasUploadCb = false;
    /** Whether callback has been defined or not. */
    public bool $hasDeleteCb = false;

    /** @var list<JsExpressionable> */
    public $jsActions = [];

    public const UPLOAD_ACTION = 'upload';
    public const DELETE_ACTION = 'delete';

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

        $this->cb = JsCallback::addTo($this);

        if ($this->action === null) {
            $this->action = new Button([
                'icon' => 'upload',
                'class.disabled' => $this->disabled || $this->readOnly,
            ]);
        }
    }

    /**
     * @param string      $fileId   Field ID for onDelete Callback
     * @param string|null $fileName Field name display to user
     */
    #[\Override]
    public function set($fileId = null, $fileName = null)
    {
        $this->setFileId($fileId);

        if ($fileName === null) {
            $fileName = $fileId;
        }

        return $this->setInput($fileName);
    }

    /**
     * Set input field value.
     *
     * @param mixed $value
     *
     * @return $this
     */
    public function setInput($value)
    {
        return parent::set($value);
    }

    /**
     * Get input field value.
     *
     * @return mixed
     */
    public function getInputValue()
    {
        return $this->entityField ? $this->entityField->get() : $this->content;
    }

    /**
     * @param string|null $id
     */
    public function setFileId($id): void
    {
        $this->fileId = $id;
    }

    /**
     * Add a JS action to be returned to server on callback.
     */
    public function addJsAction(JsExpressionable $action): void
    {
        $this->jsActions[] = $action;
    }

    /**
     * Call when user is uploading a file.
     *
     * @param \Closure(PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray): JsExpressionable $fx
     */
    public function onUpload(\Closure $fx): void
    {
        $this->hasUploadCb = true;
        if ($this->getApp()->tryGetRequestPostParam('fUploadAction') === self::UPLOAD_ACTION) {
            $this->cb->set(function () use ($fx) {
                $postFiles = [];
                for ($i = 0;; ++$i) {
                    $k = 'file' . ($i > 0 ? '-' . $i : '');
                    $uploadFile = $this->getApp()->tryGetRequestUploadedFile($k);
                    if ($uploadFile === null) {
                        break;
                    }

                    $postFile = [
                        'name' => $uploadFile->getClientFilename(),
                        'error' => $uploadFile->getError(),
                    ];
                    if ($uploadFile->getError() === \UPLOAD_ERR_OK) {
                        $postFile = array_merge($postFile, [
                            'type' => $uploadFile->getClientMediaType(),
                            'tmp_name' => $uploadFile->getStream()->getMetadata('uri'),
                            'size' => $uploadFile->getSize(),
                        ]);
                    }
                    $postFiles[] = $postFile;
                }

                if (count($postFiles) > 0) {
                    $fileId = reset($postFiles)['name'];
                    $this->setFileId($fileId);
                    $this->setInput($fileId);
                }

                $jsRes = $fx(...$postFiles);
                if ($jsRes !== null) { // @phpstan-ignore notIdentical.alwaysTrue (https://github.com/phpstan/phpstan/issues/9388)
                    $this->addJsAction($jsRes);
                }

                if (count($postFiles) > 0 && reset($postFiles)['error'] === 0) {
                    $this->addJsAction(
                        $this->js()->atkFileUpload('updateField', [$this->fileId, $this->getInputValue()])
                    );
                }

                return new JsBlock($this->jsActions);
            });
        }
    }

    /**
     * Call when user is removing an already upload file.
     *
     * @param \Closure(string): JsExpressionable $fx
     */
    public function onDelete(\Closure $fx): void
    {
        $this->hasDeleteCb = true;
        if ($this->getApp()->tryGetRequestPostParam('fUploadAction') === self::DELETE_ACTION) {
            $this->cb->set(function () use ($fx) {
                $fileId = $this->getApp()->getRequestPostParam('fUploadId');

                $jsRes = $fx($fileId);
                if ($jsRes !== null) { // @phpstan-ignore notIdentical.alwaysTrue (https://github.com/phpstan/phpstan/issues/9388)
                    $this->addJsAction($jsRes);
                }

                return new JsBlock($this->jsActions);
            });
        }
    }

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

        if ($this->cb->canTerminate()) {
            $uploadActionRaw = $this->getApp()->tryGetRequestPostParam('fUploadAction');
            if (!$this->hasUploadCb && ($uploadActionRaw === self::UPLOAD_ACTION)) {
                throw new Exception('Missing onUpload callback');
            } elseif (!$this->hasDeleteCb && ($uploadActionRaw === self::DELETE_ACTION)) {
                throw new Exception('Missing onDelete callback');
            }
        }

        if ($this->accept !== []) {
            $this->template->set('accept', implode(', ', $this->accept));
        }

        if ($this->disabled || $this->readOnly) {
            $this->template->dangerouslySetHtml('disabled', 'disabled="disabled"');
        }

        if ($this->multiple) {
            $this->template->dangerouslySetHtml('multiple', 'multiple="multiple"');
        }

        $this->template->set('placeholderReadonly', $this->disabled ? 'disabled="disabled"' : 'readonly="readonly"');

        if ($this->placeholder) {
            $this->template->set('Placeholder', $this->placeholder);
        }

        $this->js(true)->atkFileUpload([
            'url' => $this->cb->getJsUrl(),
            'action' => $this->action->name,
            'file' => ['id' => $this->fileId ?? $this->entityField->get(), 'name' => $this->getInputValue()],
            'submit' => ($this->form->buttonSave) ? $this->form->buttonSave->name : null,
        ]);
    }
}