1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Form\Control;
6:
7: use Atk4\Ui\Button;
8: use Atk4\Ui\Exception;
9: use Atk4\Ui\Js\JsBlock;
10: use Atk4\Ui\Js\JsExpressionable;
11: use Atk4\Ui\JsCallback;
12:
13: /**
14: * @phpstan-type PhpFileArray array{error: int, name: string}
15: */
16: class Upload extends Input
17: {
18: public $defaultTemplate = 'form/control/upload.html';
19:
20: public string $inputType = 'hidden';
21:
22: /**
23: * The uploaded file ID.
24: * This ID is return on form submit.
25: * If not set, will default to file name.
26: * file ID is also sent with onDelete Callback.
27: *
28: * @var string|null
29: */
30: public $fileId;
31:
32: /** @var JsCallback Callback is use for onUpload or onDelete. */
33: public $cb;
34:
35: /**
36: * Allow multiple file or not.
37: * CURRENTLY NOT SUPPORTED.
38: *
39: * @var bool
40: */
41: public $multiple = false;
42:
43: /**
44: * An array of string value for accept file type.
45: * ex: ['.jpg', '.jpeg', '.png'] or ['images/*'].
46: *
47: * @var array
48: */
49: public $accept = [];
50:
51: /** Whether callback has been defined or not. */
52: public bool $hasUploadCb = false;
53: /** Whether callback has been defined or not. */
54: public bool $hasDeleteCb = false;
55:
56: /** @var list<JsExpressionable> */
57: public $jsActions = [];
58:
59: public const UPLOAD_ACTION = 'upload';
60: public const DELETE_ACTION = 'delete';
61:
62: #[\Override]
63: protected function init(): void
64: {
65: parent::init();
66:
67: $this->cb = JsCallback::addTo($this);
68:
69: if ($this->action === null) {
70: $this->action = new Button([
71: 'icon' => 'upload',
72: 'class.disabled' => $this->disabled || $this->readOnly,
73: ]);
74: }
75: }
76:
77: /**
78: * @param string $fileId Field ID for onDelete Callback
79: * @param string|null $fileName Field name display to user
80: */
81: #[\Override]
82: public function set($fileId = null, $fileName = null)
83: {
84: $this->setFileId($fileId);
85:
86: if ($fileName === null) {
87: $fileName = $fileId;
88: }
89:
90: return $this->setInput($fileName);
91: }
92:
93: /**
94: * Set input field value.
95: *
96: * @param mixed $value
97: *
98: * @return $this
99: */
100: public function setInput($value)
101: {
102: return parent::set($value);
103: }
104:
105: /**
106: * Get input field value.
107: *
108: * @return mixed
109: */
110: public function getInputValue()
111: {
112: return $this->entityField ? $this->entityField->get() : $this->content;
113: }
114:
115: /**
116: * @param string|null $id
117: */
118: public function setFileId($id): void
119: {
120: $this->fileId = $id;
121: }
122:
123: /**
124: * Add a JS action to be returned to server on callback.
125: */
126: public function addJsAction(JsExpressionable $action): void
127: {
128: $this->jsActions[] = $action;
129: }
130:
131: /**
132: * Call when user is uploading a file.
133: *
134: * @param \Closure(PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray, PhpFileArray): JsExpressionable $fx
135: */
136: public function onUpload(\Closure $fx): void
137: {
138: $this->hasUploadCb = true;
139: if ($this->getApp()->tryGetRequestPostParam('fUploadAction') === self::UPLOAD_ACTION) {
140: $this->cb->set(function () use ($fx) {
141: $postFiles = [];
142: for ($i = 0;; ++$i) {
143: $k = 'file' . ($i > 0 ? '-' . $i : '');
144: $uploadFile = $this->getApp()->tryGetRequestUploadedFile($k);
145: if ($uploadFile === null) {
146: break;
147: }
148:
149: $postFile = [
150: 'name' => $uploadFile->getClientFilename(),
151: 'error' => $uploadFile->getError(),
152: ];
153: if ($uploadFile->getError() === \UPLOAD_ERR_OK) {
154: $postFile = array_merge($postFile, [
155: 'type' => $uploadFile->getClientMediaType(),
156: 'tmp_name' => $uploadFile->getStream()->getMetadata('uri'),
157: 'size' => $uploadFile->getSize(),
158: ]);
159: }
160: $postFiles[] = $postFile;
161: }
162:
163: if (count($postFiles) > 0) {
164: $fileId = reset($postFiles)['name'];
165: $this->setFileId($fileId);
166: $this->setInput($fileId);
167: }
168:
169: $jsRes = $fx(...$postFiles);
170: if ($jsRes !== null) { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9388
171: $this->addJsAction($jsRes);
172: }
173:
174: if (count($postFiles) > 0 && reset($postFiles)['error'] === 0) {
175: $this->addJsAction(
176: $this->js()->atkFileUpload('updateField', [$this->fileId, $this->getInputValue()])
177: );
178: }
179:
180: return new JsBlock($this->jsActions);
181: });
182: }
183: }
184:
185: /**
186: * Call when user is removing an already upload file.
187: *
188: * @param \Closure(string): JsExpressionable $fx
189: */
190: public function onDelete(\Closure $fx): void
191: {
192: $this->hasDeleteCb = true;
193: if ($this->getApp()->tryGetRequestPostParam('fUploadAction') === self::DELETE_ACTION) {
194: $this->cb->set(function () use ($fx) {
195: $fileId = $this->getApp()->getRequestPostParam('fUploadId');
196:
197: $jsRes = $fx($fileId);
198: if ($jsRes !== null) { // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9388
199: $this->addJsAction($jsRes);
200: }
201:
202: return new JsBlock($this->jsActions);
203: });
204: }
205: }
206:
207: #[\Override]
208: protected function renderView(): void
209: {
210: parent::renderView();
211:
212: if ($this->cb->canTerminate()) {
213: $uploadActionRaw = $this->getApp()->tryGetRequestPostParam('fUploadAction');
214: if (!$this->hasUploadCb && ($uploadActionRaw === self::UPLOAD_ACTION)) {
215: throw new Exception('Missing onUpload callback');
216: } elseif (!$this->hasDeleteCb && ($uploadActionRaw === self::DELETE_ACTION)) {
217: throw new Exception('Missing onDelete callback');
218: }
219: }
220:
221: if ($this->accept !== []) {
222: $this->template->set('accept', implode(', ', $this->accept));
223: }
224:
225: if ($this->disabled || $this->readOnly) {
226: $this->template->dangerouslySetHtml('disabled', 'disabled="disabled"');
227: }
228:
229: if ($this->multiple) {
230: $this->template->dangerouslySetHtml('multiple', 'multiple="multiple"');
231: }
232:
233: $this->template->set('placeholderReadonly', $this->disabled ? 'disabled="disabled"' : 'readonly="readonly"');
234:
235: if ($this->placeholder) {
236: $this->template->set('Placeholder', $this->placeholder);
237: }
238:
239: $this->js(true)->atkFileUpload([
240: 'url' => $this->cb->getJsUrl(),
241: 'action' => $this->action->name,
242: 'file' => ['id' => $this->fileId ?? $this->entityField->get(), 'name' => $this->getInputValue()],
243: 'submit' => ($this->form->buttonSave) ? $this->form->buttonSave->name : null,
244: ]);
245: }
246: }
247: