1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Form\Control;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Core\HookTrait;
9: use Atk4\Data\Model;
10: use Atk4\Ui\Button;
11: use Atk4\Ui\CallbackLater;
12: use Atk4\Ui\Form;
13: use Atk4\Ui\Js\Jquery;
14: use Atk4\Ui\Js\JsBlock;
15: use Atk4\Ui\Js\JsExpression;
16: use Atk4\Ui\Js\JsExpressionable;
17: use Atk4\Ui\Js\JsFunction;
18: use Atk4\Ui\Js\JsModal;
19: use Atk4\Ui\Js\JsToast;
20: use Atk4\Ui\VirtualPage;
21:
22: class Lookup extends Input
23: {
24: use HookTrait;
25:
26: public $defaultTemplate = 'form/control/lookup.html';
27:
28: public string $inputType = 'hidden';
29:
30: /** @var array Declare this property so Lookup is consistent as decorator to replace Form\Control\Dropdown. */
31: public $values = [];
32:
33: /** @var CallbackLater Object used to capture requests from the browser. */
34: public $callback;
35:
36: /** @var string Set this to true, to permit "empty" selection. If you set it to string, it will be used as a placeholder for empty value. */
37: public $empty = "\u{00a0}"; // Unicode NBSP
38:
39: /**
40: * Either set this to array of fields which must be searched (e.g. "name", "surname"), or define this
41: * as a callback to be executed callback($model, $query);.
42: *
43: * If left null, then search will be performed on a model's title field
44: *
45: * @var list<string>|\Closure(Model, string): void|null
46: */
47: public $search;
48:
49: /**
50: * If a dependency callback is declared Lookup collects the current (dirty) form values
51: * and passes them on to the dependency callback so conditions on the field model can be applied.
52: * This allows for generating different option lists depending on dirty form values
53: * E.g if we have a dropdown field 'country' we can add to the form an Lookup field 'state'
54: * with dependency
55: * Then model of the 'state' field can be limited to states of the currently selected 'country'.
56: *
57: * @var \Closure(Model, array<string, mixed>): void|null
58: */
59: public $dependency;
60:
61: /**
62: * Set this to create right-aligned button for adding a new a new record.
63: *
64: * true = will use "Add new" label
65: * string = will use your string
66: *
67: * @var bool|string|array|null
68: */
69: public $plus = false;
70:
71: /** @var int Sets the max. amount of records that are loaded. */
72: public $limit = 100;
73:
74: /** @var string|null Set custom model field here to use it's value as ID in dropdown instead of default model ID field. */
75: public $idField;
76:
77: /** @var string|null Set custom model field here to display it's value in dropdown instead of default model title field. */
78: public $titleField;
79:
80: /**
81: * Fomantic-UI uses cache to remember choices. For dynamic sites this may be dangerous, so
82: * it's disabled by default. To switch cache on, set 'cache' => 'local'.
83: *
84: * Use this apiConfig variable to pass API settings to Fomantic-UI in .dropdown()
85: *
86: * @var array
87: */
88: public $apiConfig = ['cache' => false];
89:
90: /**
91: * Fomantic-UI dropdown module settings.
92: * Use this setting to configure various dropdown module settings
93: * to use with Lookup.
94: *
95: * For example, using this setting will automatically submit
96: * form when field value is changes.
97: * $form->addControl('field', [Form\Control\Lookup::class, 'settings' => [
98: * 'allowReselection' => true,
99: * 'selectOnKeydown' => false,
100: * 'onChange' => new JsExpression('function (value, t, c) {
101: * if ($(this).data("value") !== value) {
102: * $(this).parents(\'.form\').form(\'submit\');
103: * $(this).data(\'value\', value);
104: * }
105: * }'),
106: * ]]);
107: *
108: * @var array
109: */
110: public $settings = [];
111:
112: /**
113: * Define callback for generating the row data
114: * If left empty default callback Lookup::defaultRenderRow is used.
115: *
116: * @var \Closure($this, Model): array{value: mixed, title: mixed}|null
117: */
118: public $renderRowFunction;
119:
120: /**
121: * Whether or not to accept multiple value.
122: * Multiple values are sent using a string with comma as value delimiter.
123: * ex: 'value1,value2,value3'.
124: *
125: * @var bool
126: */
127: public $multiple = false;
128:
129: #[\Override]
130: protected function init(): void
131: {
132: parent::init();
133:
134: $this->template->set([
135: 'placeholder' => $this->placeholder,
136: ]);
137:
138: $this->initQuickNewRecord();
139:
140: $this->callback = CallbackLater::addTo($this);
141: $this->callback->set(function () {
142: $this->outputApiResponse();
143: });
144: }
145:
146: /**
147: * @param bool|string $when
148: * @param JsExpressionable $action
149: *
150: * @return Jquery
151: */
152: protected function jsDropdown($when = false, $action = null): JsExpressionable
153: {
154: return $this->js($when, $action, 'div.ui.dropdown:has(> #' . $this->name . '_input)');
155: }
156:
157: /**
158: * Returns URL which would respond with first 50 matching records.
159: */
160: protected function getCallbackUrl(): string
161: {
162: return $this->callback->getJsUrl();
163: }
164:
165: /**
166: * Generate API response.
167: *
168: * @return never
169: */
170: public function outputApiResponse(): void
171: {
172: $this->getApp()->terminateJson([
173: 'success' => true,
174: 'results' => $this->getData(),
175: ]);
176: }
177:
178: /**
179: * Generate Lookup data.
180: *
181: * @param int|bool $limit
182: *
183: * @return array<int, array{value: mixed, title: mixed}>
184: */
185: public function getData($limit = true): array
186: {
187: $this->applyLimit($limit);
188:
189: $this->applySearchConditions();
190:
191: $this->applyDependencyConditions();
192:
193: $data = [];
194: foreach ($this->model as $row) {
195: $data[] = $this->renderRow($row);
196: }
197:
198: if (!$this->multiple && $this->empty) {
199: array_unshift($data, ['value' => '', 'title' => $this->empty]);
200: }
201:
202: return $data;
203: }
204:
205: /**
206: * Renders the Lookup row depending on properties set.
207: *
208: * @return array{value: mixed, title: mixed}
209: */
210: public function renderRow(Model $row): array
211: {
212: if ($this->renderRowFunction !== null) {
213: return ($this->renderRowFunction)($this, $row);
214: }
215:
216: return $this->defaultRenderRow($row);
217: }
218:
219: /**
220: * Default callback for generating data row.
221: *
222: * @param string $key
223: *
224: * @return array{value: mixed, title: mixed}
225: */
226: public function defaultRenderRow(Model $row, $key = null)
227: {
228: $idField = $this->idField ?? $row->idField;
229: $titleField = $this->titleField ?? $row->titleField;
230:
231: return ['value' => $row->get($idField), 'title' => $row->get($titleField)];
232: }
233:
234: /**
235: * Add button for new record.
236: */
237: protected function initQuickNewRecord(): void
238: {
239: if (!$this->plus) {
240: return;
241: }
242:
243: if ($this->plus === true) {
244: $this->plus = 'Add New';
245: }
246:
247: if (is_string($this->plus)) {
248: $this->plus = ['button' => $this->plus];
249: }
250:
251: $buttonSeed = $this->plus['button'] ?? [];
252: if (is_string($buttonSeed)) {
253: $buttonSeed = ['content' => $buttonSeed];
254: }
255:
256: $defaultSeed = [Button::class, 'class.disabled' => $this->disabled || $this->readOnly];
257: $this->action = Factory::factory(array_merge($defaultSeed, $buttonSeed));
258:
259: $vp = VirtualPage::addTo($this->form ?? $this->getOwner());
260: $vp->set(function (VirtualPage $p) {
261: $form = Form::addTo($p);
262:
263: $entity = $this->model->createEntity();
264: $form->setModel($entity, $this->plus['fields'] ?? null);
265:
266: $form->onSubmit(function (Form $form) {
267: $msg = $form->model->getUserAction('add')->execute();
268:
269: $res = new JsBlock();
270: if (is_string($msg)) {
271: $res->addStatement(new JsToast($msg));
272: }
273: $res->addStatement((new Jquery())->closest('.atk-modal')->modal('hide'));
274:
275: $row = $this->renderRow($form->model);
276: $chain = $this->jsDropdown();
277: $chain->dropdown('set value', $row['value'])->dropdown('set text', $row['title']);
278: $res->addStatement($chain);
279:
280: return $res;
281: });
282: });
283:
284: $caption = $this->plus['caption'] ?? 'Add New ' . $this->model->getModelCaption();
285: $this->action->on('click', new JsModal($caption, $vp));
286: }
287:
288: /**
289: * Apply limit to model.
290: *
291: * @param int|bool $limit
292: */
293: protected function applyLimit($limit = true): void
294: {
295: if ($limit !== false) {
296: $this->model->setLimit($limit === true ? $this->limit : $limit);
297: }
298: }
299:
300: /**
301: * Apply conditions to model based on search string.
302: */
303: protected function applySearchConditions(): void
304: {
305: $query = $this->getApp()->tryGetRequestQueryParam('q') ?? '';
306: if ($query === '') {
307: return;
308: }
309:
310: if ($this->search instanceof \Closure) {
311: ($this->search)($this->model, $query);
312: } elseif (is_array($this->search)) {
313: $scope = Model\Scope::createOr();
314: foreach ($this->search as $field) {
315: $scope->addCondition($field, 'like', '%' . $query . '%');
316: }
317: $this->model->addCondition($scope);
318: } else {
319: $titleField = $this->titleField ?? $this->model->titleField;
320:
321: $this->model->addCondition($titleField, 'like', '%' . $query . '%');
322: }
323: }
324:
325: /**
326: * Apply conditions to model based on dependency.
327: */
328: protected function applyDependencyConditions(): void
329: {
330: if (!$this->dependency instanceof \Closure) {
331: return;
332: }
333:
334: $data = [];
335: if ($this->getApp()->hasRequestQueryParam('form')) {
336: parse_str($this->getApp()->getRequestQueryParam('form'), $data);
337: } elseif ($this->form) {
338: $data = $this->form->model->get();
339: } else {
340: return;
341: }
342:
343: ($this->dependency)($this->model, $data);
344: }
345:
346: /**
347: * Override this method if you want to add more logic to the initialization of the auto-complete field.
348: *
349: * @param Jquery $chain
350: */
351: protected function initDropdown($chain): void
352: {
353: $settings = array_merge([
354: 'fields' => ['name' => 'title'],
355: 'apiSettings' => array_merge(['url' => $this->getCallbackUrl() . '&q={query}'], $this->apiConfig),
356: ], $this->settings);
357:
358: $chain->dropdown($settings);
359: }
360:
361: #[\Override]
362: protected function renderView(): void
363: {
364: if ($this->multiple) {
365: $this->template->dangerouslySetHtml('multipleClass', 'multiple');
366: }
367:
368: if ($this->disabled) {
369: $this->template->set('disabledClass', 'disabled');
370: $this->template->dangerouslySetHtml('disabled', 'disabled="disabled"');
371: } elseif ($this->readOnly) {
372: $this->template->set('disabledClass', 'read-only');
373: $this->template->dangerouslySetHtml('disabled', 'readonly="readonly"');
374:
375: $this->settings['apiSettings'] = null;
376: $this->settings['onShow'] = new JsFunction([], [new JsExpression('return false')]);
377: }
378:
379: if ($this->dependency) {
380: $this->apiConfig['data'] = array_merge([
381: 'form' => new JsFunction([], [new JsExpression('return []', [$this->form->formElement->js()->serialize()])]),
382: ], $this->apiConfig['data'] ?? []);
383: }
384:
385: $chain = $this->jsDropdown();
386:
387: $this->initDropdown($chain);
388:
389: if ($this->entityField && $this->entityField->get()) {
390: $idField = $this->idField ?? $this->model->idField;
391:
392: $this->model = $this->model->loadBy($idField, $this->entityField->get());
393:
394: $row = $this->renderRow($this->model);
395: $chain->dropdown('set value', $row['value'])->dropdown('set text', $row['title']);
396: }
397:
398: $this->js(true, $chain);
399:
400: parent::renderView();
401: }
402: }
403: